From 3f42911af4291c2e5d85806e394f02a5f62bc733 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 21 Jul 2025 10:33:23 -0500 Subject: [PATCH 001/235] Add streaming to cloud TTS (#148925) --- homeassistant/components/cloud/tts.py | 35 +++- tests/components/cloud/test_tts.py | 244 ++++++++++++++++++++------ 2 files changed, 218 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 85ca599fa87..179f467922f 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -17,6 +17,8 @@ from homeassistant.components.tts import ( PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, TextToSpeechEntity, + TTSAudioRequest, + TTSAudioResponse, TtsAudioType, Voice, ) @@ -332,7 +334,7 @@ class CloudTTSEntity(TextToSpeechEntity): def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { - ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3.value, } @property @@ -433,6 +435,29 @@ class CloudTTSEntity(TextToSpeechEntity): return (options[ATTR_AUDIO_OUTPUT], data) + async def async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: + """Generate speech from an incoming message.""" + data_gen = self.cloud.voice.process_tts_stream( + text_stream=request.message_gen, + **_prepare_voice_args( + hass=self.hass, + language=request.language, + voice=request.options.get( + ATTR_VOICE, + ( + self._voice + if request.language == self._language + else DEFAULT_VOICES[request.language] + ), + ), + gender=request.options.get(ATTR_GENDER), + ), + ) + + return TTSAudioResponse(AudioOutput.WAV.value, data_gen) + class CloudProvider(Provider): """Home Assistant Cloud speech API provider.""" @@ -526,9 +551,11 @@ class CloudProvider(Provider): language=language, voice=options.get( ATTR_VOICE, - self._voice - if language == self._language - else DEFAULT_VOICES[language], + ( + self._voice + if language == self._language + else DEFAULT_VOICES[language] + ), ), gender=options.get(ATTR_GENDER), ), diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index c920fdac264..44430f9c39a 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,10 +1,12 @@ """Tests for cloud tts.""" -from collections.abc import AsyncGenerator, Callable, Coroutine +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Coroutine from copy import deepcopy from http import HTTPStatus +import io from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import wave from hass_nabucasa.voice import VoiceError, VoiceTokenError from hass_nabucasa.voice_data import TTS_VOICES @@ -239,6 +241,12 @@ async def test_get_tts_audio( side_effect=mock_process_tts_side_effect, ) cloud.voice.process_tts = mock_process_tts + + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + if mock_process_tts_side_effect: + mock_process_tts_stream.side_effect = mock_process_tts_side_effect + cloud.voice.process_tts_stream = mock_process_tts_stream + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -262,13 +270,27 @@ async def test_get_tts_audio( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == "en-US" + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == "JennyNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" @pytest.mark.parametrize( @@ -321,10 +343,10 @@ async def test_get_tts_audio_logged_out( @pytest.mark.parametrize( - ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + ("mock_process_tts_side_effect"), [ - (b"", None), - (None, VoiceError("Boom!")), + (None,), + (VoiceError("Boom!"),), ], ) async def test_tts_entity( @@ -332,15 +354,13 @@ async def test_tts_entity( hass_client: ClientSessionGenerator, entity_registry: EntityRegistry, cloud: MagicMock, - mock_process_tts_return_value: bytes | None, mock_process_tts_side_effect: Exception | None, ) -> None: """Test text-to-speech entity.""" - mock_process_tts = AsyncMock( - return_value=mock_process_tts_return_value, - side_effect=mock_process_tts_side_effect, - ) - cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + if mock_process_tts_side_effect: + mock_process_tts_stream.side_effect = mock_process_tts_side_effect + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -372,13 +392,14 @@ async def test_tts_entity( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == "en-US" + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == "JennyNeural" state = hass.states.get(entity_id) assert state @@ -482,6 +503,8 @@ async def test_deprecated_voice( return_value=b"", ) cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -509,18 +532,34 @@ async def test_deprecated_voice( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == replacement_voice + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue( "cloud", f"deprecated_voice_{replacement_voice}" ) assert issue is None mock_process_tts.reset_mock() + mock_process_tts_stream.reset_mock() # Test with deprecated voice. data["options"] = {"voice": deprecated_voice} @@ -538,15 +577,30 @@ async def test_deprecated_voice( } await hass.async_block_till_done() + # Force streaming + await client.get(response["path"]) + issue_id = f"deprecated_voice_{deprecated_voice}" - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == replacement_voice + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", issue_id) assert issue is not None assert issue.breaks_in_ha_version == "2024.8.0" @@ -623,6 +677,8 @@ async def test_deprecated_gender( return_value=b"", ) cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -649,15 +705,30 @@ async def test_deprecated_gender( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["voice"] == "XiaoxiaoNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", "deprecated_gender") assert issue is None mock_process_tts.reset_mock() + mock_process_tts_stream.reset_mock() # Test with deprecated gender option. data["options"] = {"gender": gender_option} @@ -675,15 +746,30 @@ async def test_deprecated_gender( } await hass.async_block_till_done() + # Force streaming + await client.get(response["path"]) + issue_id = "deprecated_gender" - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] == gender_option - assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] == gender_option + assert mock_process_tts_stream.call_args.kwargs["voice"] == "XiaoxiaoNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] == gender_option + assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", issue_id) assert issue is not None assert issue.breaks_in_ha_version == "2024.10.0" @@ -772,6 +858,8 @@ async def test_tts_services( calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) mock_process_tts = AsyncMock(return_value=b"") cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -793,9 +881,51 @@ async def test_tts_services( assert response.status == HTTPStatus.OK await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == service_data[ATTR_LANGUAGE] - assert mock_process_tts.call_args.kwargs["voice"] == "GadisNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if service_data.get("entity_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert ( + mock_process_tts_stream.call_args.kwargs["language"] + == service_data[ATTR_LANGUAGE] + ) + assert mock_process_tts_stream.call_args.kwargs["voice"] == "GadisNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert ( + mock_process_tts.call_args.kwargs["language"] == service_data[ATTR_LANGUAGE] + ) + assert mock_process_tts.call_args.kwargs["voice"] == "GadisNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + + +def _make_stream_mock(expected_text: str) -> MagicMock: + """Create a mock TTS stream generator with just a WAV header.""" + with io.BytesIO() as wav_io: + wav_writer: wave.Wave_write = wave.open(wav_io, "wb") + with wav_writer: + wav_writer.setframerate(24000) + wav_writer.setsampwidth(2) + wav_writer.setnchannels(1) + + wav_io.seek(0) + wav_bytes = wav_io.getvalue() + + process_tts_stream = MagicMock() + + async def fake_process_tts_stream(*, text_stream: AsyncIterable[str], **kwargs): + # Verify text + actual_text = "".join([text_chunk async for text_chunk in text_stream]) + assert actual_text == expected_text + + # WAV header + yield wav_bytes + + process_tts_stream.side_effect = fake_process_tts_stream + + return process_tts_stream From b85ec55abb3b417e6283e1c41d8916fbe4253224 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:41:56 -0400 Subject: [PATCH 002/235] Add availability template to template helper config flow (#147623) Co-authored-by: Norbert Rittel Co-authored-by: Joostlek Co-authored-by: Erik Montnemery --- .../components/template/config_flow.py | 28 +++- homeassistant/components/template/const.py | 1 + homeassistant/components/template/helpers.py | 4 + .../components/template/strings.json | 128 ++++++++++++++++++ tests/components/template/test_config_flow.py | 63 +++++++-- 5 files changed, 208 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index d6fc5768f81..bb5ee14c7d2 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -30,6 +30,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -53,7 +54,14 @@ from .alarm_control_panel import ( async_create_preview_alarm_control_panel, ) from .binary_sensor import async_create_preview_binary_sensor -from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import ( + CONF_ADVANCED_OPTIONS, + CONF_AVAILABILITY, + CONF_PRESS, + CONF_TURN_OFF, + CONF_TURN_ON, + DOMAIN, +) from .number import ( CONF_MAX, CONF_MIN, @@ -214,7 +222,17 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), } - schema[vol.Optional(CONF_DEVICE_ID)] = selector.DeviceSelector() + schema |= { + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_ADVANCED_OPTIONS): section( + vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), + } + ), + {"collapsed": True}, + ), + } return vol.Schema(schema) @@ -530,7 +548,11 @@ def ws_start_preview( ) return - preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) + config: dict = msg["user_input"] + advanced_options = config.pop(CONF_ADVANCED_OPTIONS, {}) + preview_entity = CREATE_PREVIEW_ENTITY[template_type]( + hass, name, {**config, **advanced_options} + ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index e3e0e4fe9f5..2180567bf59 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +CONF_ADVANCED_OPTIONS = "advanced_options" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index c0177e9dd5d..25f7011c794 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -33,6 +33,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + CONF_ADVANCED_OPTIONS, CONF_ATTRIBUTE_TEMPLATES, CONF_ATTRIBUTES, CONF_AVAILABILITY, @@ -248,6 +249,9 @@ async def async_setup_template_entry( options = dict(config_entry.options) options.pop("template_type") + if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None): + options = {**options, **advanced_options} + if replace_value_template and CONF_VALUE_TEMPLATE in options: options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 7f285b4929b..a8c2e7660dc 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -19,6 +19,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template alarm control panel" }, "binary_sensor": { @@ -31,6 +39,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template binary sensor" }, "button": { @@ -43,6 +59,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template button" }, "image": { @@ -55,6 +79,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template image" }, "number": { @@ -71,6 +103,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template number" }, "select": { @@ -84,6 +124,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template select" }, "sensor": { @@ -98,6 +146,14 @@ "data_description": { "device_id": "Select a device to link to this entity." }, + "sections": { + "advanced_options": { + "name": "Advanced options", + "data": { + "availability": "Availability template" + } + } + }, "title": "Template sensor" }, "user": { @@ -126,6 +182,14 @@ "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template switch" } } @@ -149,6 +213,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::alarm_control_panel::title%]" }, "binary_sensor": { @@ -159,6 +231,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::binary_sensor::title%]" }, "button": { @@ -169,6 +249,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::button::title%]" }, "image": { @@ -180,6 +268,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::image::title%]" }, "number": { @@ -195,6 +291,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::number::title%]" }, "select": { @@ -208,6 +312,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::select::title%]" }, "sensor": { @@ -221,6 +333,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::sensor::title%]" }, "switch": { @@ -235,6 +355,14 @@ "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::switch::title%]" } } diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 2c4e24ddf71..22acb1b2292 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -8,7 +8,7 @@ from pytest_unordered import unordered from homeassistant import config_entries from homeassistant.components.template import DOMAIN, async_setup_entry -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr @@ -217,16 +217,14 @@ async def test_config_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type + availability = {"advanced_options": {"availability": "{{ True }}"}} + with patch( "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "name": "My template", - **state_template, - **extra_input, - }, + {"name": "My template", **state_template, **extra_input, **availability}, ) await hass.async_block_till_done() @@ -238,6 +236,7 @@ async def test_config_flow( "template_type": template_type, **state_template, **extra_options, + **availability, } assert len(mock_setup_entry.mock_calls) == 1 @@ -248,6 +247,7 @@ async def test_config_flow( "template_type": template_type, **state_template, **extra_options, + **availability, } state = hass.states.get(f"{template_type}.my_template") @@ -675,7 +675,7 @@ async def test_options( "{{ float(states('sensor.one'), default='') + float(states('sensor.two'), default='') }}", {}, {"one": "30.0", "two": "20.0"}, - ["", STATE_UNAVAILABLE, "50.0"], + ["", STATE_UNKNOWN, "50.0"], [{}, {}], [["one", "two"], ["one", "two"]], ), @@ -695,6 +695,9 @@ async def test_config_flow_preview( """Test the config flow preview.""" client = await hass_ws_client(hass) + hass.states.async_set("binary_sensor.available", "on") + await hass.async_block_till_done() + input_entities = ["one", "two"] result = await hass.config_entries.flow.async_init( @@ -712,12 +715,22 @@ async def test_config_flow_preview( assert result["errors"] is None assert result["preview"] == "template" + availability = { + "advanced_options": { + "availability": "{{ is_state('binary_sensor.available', 'on') }}" + } + } + await client.send_json_auto_id( { "type": "template/start_preview", "flow_id": result["flow_id"], "flow_type": "config_flow", - "user_input": {"name": "My template", "state": state_template} + "user_input": { + "name": "My template", + "state": state_template, + **availability, + } | extra_user_input, } ) @@ -725,13 +738,16 @@ async def test_config_flow_preview( assert msg["success"] assert msg["result"] is None + entities = [f"{template_type}.{_id}" for _id in listeners[0]] + entities.append("binary_sensor.available") + msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes[0], "listeners": { "all": False, "domains": [], - "entities": unordered([f"{template_type}.{_id}" for _id in listeners[0]]), + "entities": unordered(entities), "time": False, }, "state": template_states[0], @@ -743,6 +759,9 @@ async def test_config_flow_preview( ) await hass.async_block_till_done() + entities = [f"{template_type}.{_id}" for _id in listeners[1]] + entities.append("binary_sensor.available") + for template_state in template_states[1:]: msg = await client.receive_json() assert msg["event"] == { @@ -752,14 +771,32 @@ async def test_config_flow_preview( "listeners": { "all": False, "domains": [], - "entities": unordered( - [f"{template_type}.{_id}" for _id in listeners[1]] - ), + "entities": unordered(entities), "time": False, }, "state": template_state, } - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 + + # Test preview availability. + hass.states.async_set("binary_sensor.available", "off") + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My template"} + | extra_attributes[0] + | extra_attributes[1], + "listeners": { + "all": False, + "domains": [], + "entities": unordered(entities), + "time": False, + }, + "state": STATE_UNAVAILABLE, + } + + assert len(hass.states.async_all()) == 3 EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of template')" From 3bd70a4698ff0964e64e4f465864f09aa4c2f2d9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 17:51:26 +0200 Subject: [PATCH 003/235] Improve derivative sensor tests (#149179) --- tests/components/derivative/test_sensor.py | 60 +++++++++++++++++----- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index ee458ea54cd..211e6f673ca 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -16,8 +16,15 @@ from homeassistant.const import ( UnitOfPower, UnitOfTime, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -98,6 +105,14 @@ async def test_no_change( attributes: list[dict[str, Any]], ) -> None: """Test derivative sensor state updated when source sensor doesn't change.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.derivative", _capture_event) + config = { "sensor": { "platform": "derivative", @@ -110,6 +125,7 @@ async def test_no_change( } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() entity_id = config["sensor"]["source"] base = dt_util.utcnow() @@ -125,8 +141,16 @@ async def test_no_change( state = hass.states.get("sensor.derivative") assert state is not None + await hass.async_block_till_done() + await hass.async_block_till_done() + states = [events[0].data["new_state"].state] + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[1:] + ] # Testing a energy sensor at 1 kWh for 1hour = 0kW - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + assert states == ["unavailable", 0.0, 1.0, 0.0] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == "kW" @@ -268,6 +292,14 @@ async def test_data_moving_average_with_zeroes( # Therefore, we can expect the derivative to peak at 1 after 10 minutes # and then fall down to 0 in steps of 10%. + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.power", _capture_event) + temperature_values = [] for temperature in range(10): temperature_values += [temperature] @@ -296,19 +328,23 @@ async def test_data_moving_average_with_zeroes( hass.states.async_set( entity_id, value, extra_attributes, force_update=force_update ) - await hass.async_block_till_done() - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) + await hass.async_block_till_done() + await hass.async_block_till_done() - if time_window == time: - assert derivative == 1.0 - elif time_window < time < time_window * 2: - assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) - elif time == time_window * 2: - assert derivative == 0 + assert len(events[2:]) == len(times) + for time, event in zip(times, events[2:], strict=True): + state = event.data["new_state"] + derivative = round(float(state.state), config["sensor"]["round"]) - last_derivative = derivative + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: From 7d895653fbe2b7681391bdfe717514139f500751 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 21 Jul 2025 18:19:56 +0200 Subject: [PATCH 004/235] Bump reolink-aio to 0.14.3 (#149191) --- homeassistant/components/reolink/diagnostics.py | 2 +- homeassistant/components/reolink/entity.py | 2 +- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/sensor.py | 13 ++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/conftest.py | 2 +- tests/components/reolink/test_media_source.py | 1 + tests/components/reolink/test_sensor.py | 2 +- 9 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index c5085c9ca18..d940bda2680 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -42,7 +42,7 @@ async def async_get_config_entry_diagnostics( "Baichuan port": api.baichuan.port, "Baichuan only": api.baichuan_only, "WiFi connection": api.wifi_connection, - "WiFi signal": api.wifi_signal, + "WiFi signal": api.wifi_signal(), "RTMP enabled": api.rtmp_enabled, "RTSP enabled": api.rtsp_enabled, "ONVIF enabled": api.onvif_enabled, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index a83dc259e1b..971b7ec4be1 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -167,7 +167,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data, coordinator) self._channel = channel - if self._host.api.supported(channel, "UID"): + if self._host.api.is_nvr and self._host.api.supported(channel, "UID"): self._attr_unique_id = f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}_{self.entity_description.key}" else: self._attr_unique_id = ( diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index c422af292b9..f8b8191a851 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.2"] + "requirements": ["reolink-aio==0.14.3"] } diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 85de03dd1a3..d63655d1173 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -16,7 +16,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -123,12 +128,14 @@ SENSORS = ( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", - cmd_key="GetWifiSignal", + cmd_key="115", translation_key="wifi_signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, - value=lambda api: api.wifi_signal, + value=lambda api: api.wifi_signal(), supported=lambda api: api.supported(None, "wifi") and api.wifi_connection, ), ReolinkHostSensorEntityDescription( diff --git a/requirements_all.txt b/requirements_all.txt index 513422df915..074b68773da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.2 +reolink-aio==0.14.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fac0aba573..10be4658356 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2209,7 +2209,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.2 +reolink-aio==0.14.3 # homeassistant.components.rflink rflink==0.0.67 diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 1ca6bb4eb55..a3e28f49194 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -123,7 +123,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 host_mock.wifi_connection = False - host_mock.wifi_signal = None + host_mock.wifi_signal.return_value = None host_mock.whiteled_mode_list.return_value = [] host_mock.zoom_range.return_value = { "zoom": {"pos": {"min": 0, "max": 100}}, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 67ae78e5fa4..31da3b213be 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -284,6 +284,7 @@ async def test_browsing_h265_encoding( ) -> None: """Test browsing a Reolink camera with h265 stream encoding.""" entry_id = config_entry.entry_id + reolink_connect.is_nvr = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index df164634355..3a120889a98 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -22,7 +22,7 @@ async def test_sensors( """Test sensor entities.""" reolink_connect.ptz_pan_position.return_value = 1200 reolink_connect.wifi_connection = True - reolink_connect.wifi_signal = 3 + reolink_connect.wifi_signal.return_value = 3 reolink_connect.hdd_list = [0] reolink_connect.hdd_storage.return_value = 95 From 941d3c2be469014571cca4ace312370f52be4fa5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 18:23:58 +0200 Subject: [PATCH 005/235] Improve integration sensor tests (#149180) --- tests/components/integration/test_sensor.py | 186 ++++++++++++++------ 1 file changed, 135 insertions(+), 51 deletions(-) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 3d5549d88bf..bda0cefb572 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -21,12 +21,19 @@ from homeassistant.const import ( UnitOfTime, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import ( condition, device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -297,24 +304,32 @@ async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> No @pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ - # time, value, attributes, expected + # time, value, attributes ( - (0, 0, {}, 0), - (20, 10, {}, 1.67), - (30, 30, {}, 5.0), - (40, 5, {}, 7.92), - (50, 5, {}, 8.75), # This fires a state report - (60, 0, {}, 9.17), + ( + (0, 0, {}), + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (0, 1.67, 5.0, 7.92, 8.75, 9.58, 10.0), ), ( - (0, 0, {}, 0), - (20, 10, {}, 1.67), - (30, 30, {}, 5.0), - (40, 5, {}, 7.92), - (50, 5, {"foo": "bar"}, 8.75), # This fires a state change - (60, 0, {}, 9.17), + ( + (0, 0, {}), + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), # This fires a state change + (60, 5, {"foo": "baz"}), # This fires a state change + (70, 0, {}), + ), + (0, 1.67, 5.0, 7.92, 8.75, 9.58, 10.0), ), ], ) @@ -323,8 +338,17 @@ async def test_trapezoidal( sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: """Test integration sensor state.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -349,7 +373,7 @@ async def test_trapezoidal( start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: # Testing a power sensor with non-monotonic intervals and values - for time, value, extra_attributes, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, @@ -357,32 +381,45 @@ async def test_trapezoidal( {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = [events[0].data["new_state"].state] + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[1:] + ] + assert states == ["unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR @pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ - # time, value, attributes, expected - ( - (20, 10, {}, 0.0), - (30, 30, {}, 1.67), - (40, 5, {}, 6.67), - (50, 5, {}, 7.5), # This fires a state report - (60, 0, {}, 8.33), + ( # time, value, attributes, expected + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (0, 1.67, 6.67, 7.5, 8.33, 9.17), ), ( - (20, 10, {}, 0.0), - (30, 30, {}, 1.67), - (40, 5, {}, 6.67), - (50, 5, {"foo": "bar"}, 7.5), # This fires a state change - (60, 0, {}, 8.33), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), + (60, 5, {"foo": "baz"}), + (70, 0, {}), + ), + (0, 1.67, 6.67, 7.5, 8.33, 9.17), ), ], ) @@ -391,8 +428,17 @@ async def test_left( sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: """Test integration sensor state with left Riemann method.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -420,7 +466,7 @@ async def test_left( # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, extra_attributes, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, @@ -428,31 +474,50 @@ async def test_left( {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = ( + [events[0].data["new_state"].state] + + [events[1].data["new_state"].state] + + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[2:] + ] + ) + assert states == ["unknown", "unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR @pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ + # time, value, attributes, expected ( - (20, 10, {}, 3.33), - (30, 30, {}, 8.33), - (40, 5, {}, 9.17), - (50, 5, {}, 10.0), # This fires a state report - (60, 0, {}, 10.0), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (3.33, 8.33, 9.17, 10.0, 10.83), ), ( - (20, 10, {}, 3.33), - (30, 30, {}, 8.33), - (40, 5, {}, 9.17), - (50, 5, {"foo": "bar"}, 10.0), # This fires a state change - (60, 0, {}, 10.0), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), # This fires a state change + (60, 5, {"foo": "baz"}), # This fires a state change + (70, 0, {}), + ), + (3.33, 8.33, 9.17, 10.0, 10.83), ), ], ) @@ -461,8 +526,17 @@ async def test_right( sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: """Test integration sensor state with right Riemann method.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -490,7 +564,7 @@ async def test_right( # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, extra_attributes, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, @@ -498,10 +572,20 @@ async def test_right( {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = ( + [events[0].data["new_state"].state] + + [events[1].data["new_state"].state] + + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[2:] + ] + ) + assert states == ["unknown", "unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR From b6014da1212f18f2a90d847c912e3774ba7d59c9 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 21 Jul 2025 21:35:28 +0200 Subject: [PATCH 006/235] Add Reolink wifi signal sensor for IPC cams (#149200) --- homeassistant/components/reolink/diagnostics.py | 2 ++ homeassistant/components/reolink/sensor.py | 12 ++++++++++++ tests/components/reolink/conftest.py | 2 +- .../reolink/snapshots/test_diagnostics.ambr | 3 ++- tests/components/reolink/test_sensor.py | 4 ++-- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index d940bda2680..48f6b709c23 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -24,6 +24,8 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch) IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + if (signal := api.wifi_signal(ch)) is not None: + IPC_cam[ch]["WiFi signal"] = signal chimes: dict[int, dict[str, Any]] = {} for chime in api.chime_list: diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index d63655d1173..cd03f2b59b5 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -123,6 +123,18 @@ SENSORS = ( value=lambda api, ch: api.baichuan.day_night_state(ch), supported=lambda api, ch: api.supported(ch, "day_night_state"), ), + ReolinkSensorEntityDescription( + key="wifi_signal", + cmd_key="115", + translation_key="wifi_signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + value=lambda api, ch: api.wifi_signal(ch), + supported=lambda api, ch: api.supported(ch, "wifi"), + ), ) HOST_SENSORS = ( diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a3e28f49194..4e2179dcd2c 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -123,7 +123,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 host_mock.wifi_connection = False - host_mock.wifi_signal.return_value = None + host_mock.wifi_signal.return_value = -45 host_mock.whiteled_mode_list.return_value = [] host_mock.zoom_range.return_value = { "zoom": {"pos": {"min": 0, "max": 100}}, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index a6d7f14a149..25a9dc299aa 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -28,6 +28,7 @@ 'HTTPS': True, 'IPC cams': dict({ '0': dict({ + 'WiFi signal': -45, 'encoding main': 'h264', 'firmware version': 'v1.1.0.0.0.0000', 'hardware version': 'IPC_00001', @@ -38,7 +39,7 @@ 'RTMP enabled': True, 'RTSP enabled': True, 'WiFi connection': False, - 'WiFi signal': None, + 'WiFi signal': -45, 'abilities': dict({ 'abilityChn': list([ dict({ diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index 3a120889a98..c3fe8d89951 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -22,7 +22,7 @@ async def test_sensors( """Test sensor entities.""" reolink_connect.ptz_pan_position.return_value = 1200 reolink_connect.wifi_connection = True - reolink_connect.wifi_signal.return_value = 3 + reolink_connect.wifi_signal.return_value = -55 reolink_connect.hdd_list = [0] reolink_connect.hdd_storage.return_value = 95 @@ -35,7 +35,7 @@ async def test_sensors( assert hass.states.get(entity_id).state == "1200" entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_wi_fi_signal" - assert hass.states.get(entity_id).state == "3" + assert hass.states.get(entity_id).state == "-55" entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_sd_0_storage" assert hass.states.get(entity_id).state == "95" From ecb6cc50b9af62d2fe43b6e13888a9e14476183f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 21 Jul 2025 22:48:02 +0200 Subject: [PATCH 007/235] Add Reolink post recording time select entity (#149201) Co-authored-by: Norbert Rittel --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/select.py | 11 +++++++++++ homeassistant/components/reolink/strings.json | 3 +++ tests/components/reolink/conftest.py | 1 + 4 files changed, 18 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 875af48e47c..0c9831af2a8 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -389,6 +389,9 @@ }, "packing_time": { "default": "mdi:record-rec" + }, + "post_rec_time": { + "default": "mdi:record-rec" } }, "sensor": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 2ee2b790687..d55cf9386f9 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -250,6 +250,17 @@ SELECT_ENTITIES = ( value=lambda api, ch: str(api.bit_rate(ch, "sub")), method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"), ), + ReolinkSelectEntityDescription( + key="post_rec_time", + cmd_key="GetRec", + translation_key="post_rec_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + get_options=lambda api, ch: api.post_recording_time_list(ch), + supported=lambda api, ch: api.supported(ch, "post_rec_time"), + value=lambda api, ch: api.post_recording_time(ch), + method=lambda api, ch, value: api.set_post_recording_time(ch, value), + ), ) HOST_SELECT_ENTITIES = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5473887a8ff..1b155af6a4d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -857,6 +857,9 @@ }, "packing_time": { "name": "Recording packing time" + }, + "post_rec_time": { + "name": "Post-recording time" } }, "sensor": { diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 4e2179dcd2c..a5f528edef6 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -125,6 +125,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.wifi_connection = False host_mock.wifi_signal.return_value = -45 host_mock.whiteled_mode_list.return_value = [] + host_mock.post_recording_time_list.return_value = [] host_mock.zoom_range.return_value = { "zoom": {"pos": {"min": 0, "max": 100}}, "focus": {"pos": {"min": 0, "max": 100}}, From 79dd91ebc61afbc4cb65d6b4162880615bca8850 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Mon, 21 Jul 2025 22:52:24 +0200 Subject: [PATCH 008/235] Add sauna light control in Huum (#149169) --- homeassistant/components/huum/const.py | 6 +- homeassistant/components/huum/light.py | 62 +++++++++++++++ tests/components/huum/conftest.py | 6 ++ .../components/huum/snapshots/test_light.ambr | 58 ++++++++++++++ tests/components/huum/test_light.py | 76 +++++++++++++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/huum/light.py create mode 100644 tests/components/huum/snapshots/test_light.ambr create mode 100644 tests/components/huum/test_light.py diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 6691a2ad8b3..13663d31cd0 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -4,4 +4,8 @@ from homeassistant.const import Platform DOMAIN = "huum" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT] + +CONFIG_STEAMER = 1 +CONFIG_LIGHT = 2 +CONFIG_STEAMER_AND_LIGHT = 3 diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py new file mode 100644 index 00000000000..8eb35afdda2 --- /dev/null +++ b/homeassistant/components/huum/light.py @@ -0,0 +1,62 @@ +"""Control for light.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up light if applicable.""" + coordinator = config_entry.runtime_data + + # Light is configured for this sauna. + if coordinator.data.config in [CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT]: + async_add_entities([HuumLight(coordinator)]) + + +class HuumLight(HuumBaseEntity, LightEntity): + """Representation of a light.""" + + _attr_name = "Light" + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_color_mode = ColorMode.ONOFF + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the light.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.config_entry.entry_id + + @property + def is_on(self) -> bool | None: + """Return the current light status.""" + return self.coordinator.data.light == 1 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + if not self.is_on: + await self._toggle_light() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn device off.""" + if self.is_on: + await self._toggle_light() + + async def _toggle_light(self) -> None: + await self.coordinator.huum.toggle_light() + await self.coordinator.async_refresh() diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py index 023abd4429e..8342603a30d 100644 --- a/tests/components/huum/conftest.py +++ b/tests/components/huum/conftest.py @@ -29,8 +29,13 @@ def mock_huum() -> Generator[AsyncMock]: "homeassistant.components.huum.coordinator.Huum.turn_on", return_value=huum, ) as turn_on, + patch( + "homeassistant.components.huum.coordinator.Huum.toggle_light", + return_value=huum, + ) as toggle_light, ): huum.status = SaunaStatus.ONLINE_NOT_HEATING + huum.config = 3 huum.door_closed = True huum.temperature = 30 huum.sauna_name = 123456 @@ -45,6 +50,7 @@ def mock_huum() -> Generator[AsyncMock]: huum.sauna_config.max_timer = 0 huum.sauna_config.min_timer = 0 huum.turn_on = turn_on + huum.toggle_light = toggle_light yield huum diff --git a/tests/components/huum/snapshots/test_light.ambr b/tests/components/huum/snapshots/test_light.ambr new file mode 100644 index 00000000000..918210272b2 --- /dev/null +++ b/tests/components/huum/snapshots/test_light.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_light[light.huum_sauna_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.huum_sauna_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_light[light.huum_sauna_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Huum sauna Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.huum_sauna_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/huum/test_light.py b/tests/components/huum/test_light.py new file mode 100644 index 00000000000..8ad12a36f4e --- /dev/null +++ b/tests/components/huum/test_light.py @@ -0,0 +1,76 @@ +"""Tests for the Huum light entity.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "light.huum_sauna_light" + + +async def test_light( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off light.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + Platform.LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_huum.toggle_light.assert_called_once() + + +async def test_light_turn_on( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning on light.""" + mock_huum.light = 0 + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + Platform.LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_huum.toggle_light.assert_called_once() From ef2531d28da435f43fdba0bece6a44ac9604fbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 21 Jul 2025 20:52:48 +0000 Subject: [PATCH 009/235] Add diagnostics support to Huawei LTE (#131085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: abmantis Co-authored-by: Abílio Costa --- .../components/huawei_lte/diagnostics.py | 86 +++++ tests/components/huawei_lte/__init__.py | 317 ++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 201 +++++++++++ .../components/huawei_lte/test_diagnostics.py | 38 +++ 4 files changed, 642 insertions(+) create mode 100644 homeassistant/components/huawei_lte/diagnostics.py create mode 100644 tests/components/huawei_lte/snapshots/test_diagnostics.ambr create mode 100644 tests/components/huawei_lte/test_diagnostics.py diff --git a/homeassistant/components/huawei_lte/diagnostics.py b/homeassistant/components/huawei_lte/diagnostics.py new file mode 100644 index 00000000000..975ab476e6c --- /dev/null +++ b/homeassistant/components/huawei_lte/diagnostics.py @@ -0,0 +1,86 @@ +"""Diagnostics support for Huawei LTE.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +ENTRY_FIELDS_DATA_TO_REDACT = { + "mac", + "username", + "password", +} +DEVICE_INFORMATION_DATA_TO_REDACT = { + "SerialNumber", + "Imei", + "Imsi", + "Iccid", + "Msisdn", + "MacAddress1", + "MacAddress2", + "WanIPAddress", + "wan_dns_address", + "WanIPv6Address", + "wan_ipv6_dns_address", + "Mccmnc", + "WifiMacAddrWl0", + "WifiMacAddrWl1", +} +DEVICE_SIGNAL_DATA_TO_REDACT = { + "pci", + "cell_id", + "enodeb_id", + "rac", + "lac", + "tac", + "nei_cellid", + "plmn", + "bsic", +} +MONITORING_STATUS_DATA_TO_REDACT = { + "PrimaryDns", + "SecondaryDns", + "PrimaryIPv6Dns", + "SecondaryIPv6Dns", +} +NET_CURRENT_PLMN_DATA_TO_REDACT = { + "net_current_plmn", +} +LAN_HOST_INFO_DATA_TO_REDACT = { + "lan_host_info", +} +WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT = { + "Ssid", + "WifiSsid", +} +WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT = { + "WifiMac", +} +TO_REDACT = { + *ENTRY_FIELDS_DATA_TO_REDACT, + *DEVICE_INFORMATION_DATA_TO_REDACT, + *DEVICE_SIGNAL_DATA_TO_REDACT, + *MONITORING_STATUS_DATA_TO_REDACT, + *NET_CURRENT_PLMN_DATA_TO_REDACT, + *LAN_HOST_INFO_DATA_TO_REDACT, + *WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT, + *WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "entry": entry.data, + "router": hass.data[DOMAIN].routers[entry.entry_id].data, + }, + TO_REDACT, + ) diff --git a/tests/components/huawei_lte/__init__.py b/tests/components/huawei_lte/__init__.py index 2d43a5eade1..f9f16a2473c 100644 --- a/tests/components/huawei_lte/__init__.py +++ b/tests/components/huawei_lte/__init__.py @@ -21,3 +21,320 @@ def magic_client(multi_basic_settings_value: dict) -> MagicMock: wifi_feature_switch=wifi_feature_switch, ) return MagicMock(device=device, monitoring=monitoring, wlan=wlan) + + +def magic_client_full() -> MagicMock: + """Extended mock for huawei_lte.Client with all API methods.""" + information = MagicMock( + return_value={ + "DeviceName": "Test Router", + "SerialNumber": "test-serial-number", + "Imei": "123456789012345", + "Imsi": "123451234567890", + "Iccid": "12345678901234567890", + "Msisdn": None, + "HardwareVersion": "1.0.0", + "SoftwareVersion": "2.0.0", + "WebUIVersion": "3.0.0", + "MacAddress1": "22:22:33:44:55:66", + "MacAddress2": None, + "WanIPAddress": "23.215.0.138", + "wan_dns_address": "8.8.8.8", + "WanIPv6Address": "2600:1406:3a00:21::173e:2e66", + "wan_ipv6_dns_address": "2001:4860:4860:0:0:0:0:8888", + "ProductFamily": "LTE", + "Classify": "cpe", + "supportmode": "LTE|WCDMA|GSM", + "workmode": "LTE", + "submask": "255.255.255.255", + "Mccmnc": "20499", + "iniversion": "test-ini-version", + "uptime": "4242424", + "ImeiSvn": "01", + "WifiMacAddrWl0": "22:22:33:44:55:77", + "WifiMacAddrWl1": "22:22:33:44:55:88", + "spreadname_en": "Huawei 4G Router N123", + "spreadname_zh": "\u534e\u4e3a4G\u8def\u7531 N123", + } + ) + basic_information = MagicMock( + return_value={ + "classify": "cpe", + "devicename": "Test Router", + "multimode": "0", + "productfamily": "LTE", + "restore_default_status": "0", + "sim_save_pin_enable": "1", + "spreadname_en": "Huawei 4G Router N123", + "spreadname_zh": "\u534e\u4e3a4G\u8def\u7531 N123", + } + ) + signal = MagicMock( + return_value={ + "pci": "123", + "sc": None, + "cell_id": "12345678", + "rssi": "-70dBm", + "rsrp": "-100dBm", + "rsrq": "-10.0dB", + "sinr": "10dB", + "rscp": None, + "ecio": None, + "mode": "7", + "ulbandwidth": "20MHz", + "dlbandwidth": "20MHz", + "txpower": "PPusch:-1dBm PPucch:-11dBm PSrs:10dBm PPrach:0dBm", + "tdd": None, + "ul_mcs": "mcsUpCarrier1:20", + "dl_mcs": "mcsDownCarrier1Code0:8 mcsDownCarrier1Code1:9", + "earfcn": "DL:123 UL:45678", + "rrc_status": "1", + "rac": None, + "lac": None, + "tac": "12345", + "band": "1", + "nei_cellid": "23456789", + "plmn": "20499", + "ims": "0", + "wdlfreq": None, + "lteulfreq": "19697", + "ltedlfreq": "21597", + "transmode": "TM[4]", + "enodeb_id": "0012345", + "cqi0": "11", + "cqi1": "5", + "ulfrequency": "1969700kHz", + "dlfrequency": "2159700kHz", + "arfcn": None, + "bsic": None, + "rxlev": None, + } + ) + + check_notifications = MagicMock( + return_value={ + "UnreadMessage": "2", + "SmsStorageFull": "0", + "OnlineUpdateStatus": "42", + "SimOperEvent": "0", + } + ) + status = MagicMock( + return_value={ + "ConnectionStatus": "901", + "WifiConnectionStatus": None, + "SignalStrength": None, + "SignalIcon": "5", + "CurrentNetworkType": "19", + "CurrentServiceDomain": "3", + "RoamingStatus": "0", + "BatteryStatus": None, + "BatteryLevel": None, + "BatteryPercent": None, + "simlockStatus": "0", + "PrimaryDns": "8.8.8.8", + "SecondaryDns": "8.8.4.4", + "wififrequence": "1", + "flymode": "0", + "PrimaryIPv6Dns": "2001:4860:4860:0:0:0:0:8888", + "SecondaryIPv6Dns": "2001:4860:4860:0:0:0:0:8844", + "CurrentWifiUser": "42", + "TotalWifiUser": "64", + "currenttotalwifiuser": "0", + "ServiceStatus": "2", + "SimStatus": "1", + "WifiStatus": "1", + "CurrentNetworkTypeEx": "101", + "maxsignal": "5", + "wifiindooronly": "0", + "cellroam": "1", + "classify": "cpe", + "usbup": "0", + "wifiswitchstatus": "1", + "WifiStatusExCustom": "0", + "hvdcp_online": "0", + } + ) + month_statistics = MagicMock( + return_value={ + "CurrentMonthDownload": "1000000000", + "CurrentMonthUpload": "500000000", + "MonthDuration": "720000", + "MonthLastClearTime": "2025-07-01", + "CurrentDayUsed": "123456789", + "CurrentDayDuration": "10000", + } + ) + traffic_statistics = MagicMock( + return_value={ + "CurrentConnectTime": "123456", + "CurrentUpload": "2000000000", + "CurrentDownload": "5000000000", + "CurrentDownloadRate": "700", + "CurrentUploadRate": "600", + "TotalUpload": "20000000000", + "TotalDownload": "50000000000", + "TotalConnectTime": "1234567", + "showtraffic": "1", + } + ) + + current_plmn = MagicMock( + return_value={ + "State": "1", + "FullName": "Test Network", + "ShortName": "Test", + "Numeric": "12345", + } + ) + net_mode = MagicMock( + return_value={ + "NetworkMode": "03", + "NetworkBand": "3FFFFFFF", + "LTEBand": "7FFFFFFFFFFFFFFF", + } + ) + + sms_count = MagicMock( + return_value={ + "LocalUnread": "0", + "LocalInbox": "5", + "LocalOutbox": "2", + "LocalDraft": "1", + "LocalDeleted": "0", + "SimUnread": "0", + "SimInbox": "0", + "SimOutbox": "0", + "SimDraft": "0", + "LocalMax": "500", + "SimMax": "30", + "SimUsed": "0", + "NewMsg": "0", + } + ) + + mobile_dataswitch = MagicMock(return_value={"dataswitch": "1"}) + + lan_host_info = MagicMock( + return_value={ + "Hosts": { + "Host": [ + { + "Active": "0", + "ActualName": "TestDevice1", + "AddressSource": "DHCP", + "AssociatedSsid": None, + "AssociatedTime": None, + "HostName": "TestDevice1", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.9.", + "InterfaceType": "Wireless", + "IpAddress": "192.168.1.100", + "LeaseTime": "2204542", + "MacAddress": "AA:BB:CC:DD:EE:FF", + "isLocalDevice": "0", + }, + { + "Active": "1", + "ActualName": "TestDevice2", + "AddressSource": "DHCP", + "AssociatedSsid": "TestSSID", + "AssociatedTime": "258632", + "HostName": "TestDevice2", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.17.", + "InterfaceType": "Wireless", + "IpAddress": "192.168.1.101", + "LeaseTime": "552115", + "MacAddress": "11:22:33:44:55:66", + "isLocalDevice": "0", + }, + ] + } + } + ) + wlan_host_list = MagicMock( + return_value={ + "Hosts": { + "Host": [ + { + "ActualName": "TestDevice2", + "AssociatedSsid": "TestSSID", + "AssociatedTime": "258632", + "Frequency": "2.4GHz", + "HostName": "TestDevice2", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.17.", + "IpAddress": "192.168.1.101;fe80::b222:33ff:fe44:5566", + "MacAddress": "11:22:33:44:55:66", + } + ] + } + } + ) + multi_basic_settings = MagicMock( + return_value={"Ssid": [{"wifiisguestnetwork": "1", "WifiEnable": "0"}]} + ) + wifi_feature_switch = MagicMock( + return_value={ + "wifi_dbdc_enable": "0", + "acmode_enable": "1", + "wifiautocountry_enabled": "0", + "wps_cancel_enable": "1", + "wifimacfilterextendenable": "1", + "wifimaxmacfilternum": "32", + "paraimmediatework_enable": "1", + "guestwifi_enable": "0", + "wifi5gnamepostfix": "_5G", + "wifiguesttimeextendenable": "1", + "chinesessid_enable": "0", + "isdoublechip": "1", + "opennonewps_enable": "1", + "wifi_country_enable": "0", + "wifi5g_enabled": "1", + "wifiwpsmode": "0", + "pmf_enable": "1", + "support_trigger_dualband_wps": "1", + "maxapnum": "4", + "wifi_chip_maxassoc": "32", + "wifiwpssuportwepnone": "0", + "maxassocoffloadon": None, + "guidefrequencyenable": "0", + "showssid_enable": "0", + "wifishowradioswitch": "3", + "wifispecialcharenable": "1", + "wifi24g_switch_enable": "1", + "wifi_dfs_enable": "0", + "show_maxassoc": "0", + "hilink_dbho_enable": "1", + "oledshowpassword": "1", + "doubleap5g_enable": "0", + "wps_switch_enable": "1", + } + ) + + device = MagicMock( + information=information, basic_information=basic_information, signal=signal + ) + monitoring = MagicMock( + check_notifications=check_notifications, + status=status, + month_statistics=month_statistics, + traffic_statistics=traffic_statistics, + ) + net = MagicMock(current_plmn=current_plmn, net_mode=net_mode) + sms = MagicMock(sms_count=sms_count) + dial_up = MagicMock(mobile_dataswitch=mobile_dataswitch) + lan = MagicMock(host_info=lan_host_info) + wlan = MagicMock( + multi_basic_settings=multi_basic_settings, + wifi_feature_switch=wifi_feature_switch, + host_list=wlan_host_list, + ) + + return MagicMock( + device=device, + monitoring=monitoring, + net=net, + sms=sms, + dial_up=dial_up, + lan=lan, + wlan=wlan, + ) diff --git a/tests/components/huawei_lte/snapshots/test_diagnostics.ambr b/tests/components/huawei_lte/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..0c2076d9c63 --- /dev/null +++ b/tests/components/huawei_lte/snapshots/test_diagnostics.ambr @@ -0,0 +1,201 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'mac': '**REDACTED**', + 'url': 'http://huawei-lte.example.com', + }), + 'router': dict({ + 'device_information': dict({ + 'Classify': 'cpe', + 'DeviceName': 'Test Router', + 'HardwareVersion': '1.0.0', + 'Iccid': '**REDACTED**', + 'Imei': '**REDACTED**', + 'ImeiSvn': '01', + 'Imsi': '**REDACTED**', + 'MacAddress1': '**REDACTED**', + 'MacAddress2': None, + 'Mccmnc': '**REDACTED**', + 'Msisdn': None, + 'ProductFamily': 'LTE', + 'SerialNumber': '**REDACTED**', + 'SoftwareVersion': '2.0.0', + 'WanIPAddress': '**REDACTED**', + 'WanIPv6Address': '**REDACTED**', + 'WebUIVersion': '3.0.0', + 'WifiMacAddrWl0': '**REDACTED**', + 'WifiMacAddrWl1': '**REDACTED**', + 'iniversion': 'test-ini-version', + 'spreadname_en': 'Huawei 4G Router N123', + 'spreadname_zh': '华为4G路由 N123', + 'submask': '255.255.255.255', + 'supportmode': 'LTE|WCDMA|GSM', + 'uptime': '4242424', + 'wan_dns_address': '**REDACTED**', + 'wan_ipv6_dns_address': '**REDACTED**', + 'workmode': 'LTE', + }), + 'device_signal': dict({ + 'arfcn': None, + 'band': '1', + 'bsic': None, + 'cell_id': '**REDACTED**', + 'cqi0': '11', + 'cqi1': '5', + 'dl_mcs': 'mcsDownCarrier1Code0:8 mcsDownCarrier1Code1:9', + 'dlbandwidth': '20MHz', + 'dlfrequency': '2159700kHz', + 'earfcn': 'DL:123 UL:45678', + 'ecio': None, + 'enodeb_id': '**REDACTED**', + 'ims': '0', + 'lac': None, + 'ltedlfreq': '21597', + 'lteulfreq': '19697', + 'mode': '7', + 'nei_cellid': '**REDACTED**', + 'pci': '**REDACTED**', + 'plmn': '**REDACTED**', + 'rac': None, + 'rrc_status': '1', + 'rscp': None, + 'rsrp': '-100dBm', + 'rsrq': '-10.0dB', + 'rssi': '-70dBm', + 'rxlev': None, + 'sc': None, + 'sinr': '10dB', + 'tac': '**REDACTED**', + 'tdd': None, + 'transmode': 'TM[4]', + 'txpower': 'PPusch:-1dBm PPucch:-11dBm PSrs:10dBm PPrach:0dBm', + 'ul_mcs': 'mcsUpCarrier1:20', + 'ulbandwidth': '20MHz', + 'ulfrequency': '1969700kHz', + 'wdlfreq': None, + }), + 'dialup_mobile_dataswitch': dict({ + 'dataswitch': '1', + }), + 'lan_host_info': '**REDACTED**', + 'monitoring_check_notifications': dict({ + 'OnlineUpdateStatus': '42', + 'SimOperEvent': '0', + 'SmsStorageFull': '0', + 'UnreadMessage': '2', + }), + 'monitoring_month_statistics': dict({ + 'CurrentDayDuration': '10000', + 'CurrentDayUsed': '123456789', + 'CurrentMonthDownload': '1000000000', + 'CurrentMonthUpload': '500000000', + 'MonthDuration': '720000', + 'MonthLastClearTime': '2025-07-01', + }), + 'monitoring_status': dict({ + 'BatteryLevel': None, + 'BatteryPercent': None, + 'BatteryStatus': None, + 'ConnectionStatus': '901', + 'CurrentNetworkType': '19', + 'CurrentNetworkTypeEx': '101', + 'CurrentServiceDomain': '3', + 'CurrentWifiUser': '42', + 'PrimaryDns': '**REDACTED**', + 'PrimaryIPv6Dns': '**REDACTED**', + 'RoamingStatus': '0', + 'SecondaryDns': '**REDACTED**', + 'SecondaryIPv6Dns': '**REDACTED**', + 'ServiceStatus': '2', + 'SignalIcon': '5', + 'SignalStrength': None, + 'SimStatus': '1', + 'TotalWifiUser': '64', + 'WifiConnectionStatus': None, + 'WifiStatus': '1', + 'WifiStatusExCustom': '0', + 'cellroam': '1', + 'classify': 'cpe', + 'currenttotalwifiuser': '0', + 'flymode': '0', + 'hvdcp_online': '0', + 'maxsignal': '5', + 'simlockStatus': '0', + 'usbup': '0', + 'wififrequence': '1', + 'wifiindooronly': '0', + 'wifiswitchstatus': '1', + }), + 'monitoring_traffic_statistics': dict({ + 'CurrentConnectTime': '123456', + 'CurrentDownload': '5000000000', + 'CurrentDownloadRate': '700', + 'CurrentUpload': '2000000000', + 'CurrentUploadRate': '600', + 'TotalConnectTime': '1234567', + 'TotalDownload': '50000000000', + 'TotalUpload': '20000000000', + 'showtraffic': '1', + }), + 'net_current_plmn': '**REDACTED**', + 'net_net_mode': dict({ + 'LTEBand': '7FFFFFFFFFFFFFFF', + 'NetworkBand': '3FFFFFFF', + 'NetworkMode': '03', + }), + 'sms_sms_count': dict({ + 'LocalDeleted': '0', + 'LocalDraft': '1', + 'LocalInbox': '5', + 'LocalMax': '500', + 'LocalOutbox': '2', + 'LocalUnread': '0', + 'NewMsg': '0', + 'SimDraft': '0', + 'SimInbox': '0', + 'SimMax': '30', + 'SimOutbox': '0', + 'SimUnread': '0', + 'SimUsed': '0', + }), + 'wlan_wifi_feature_switch': dict({ + 'acmode_enable': '1', + 'chinesessid_enable': '0', + 'doubleap5g_enable': '0', + 'guestwifi_enable': '0', + 'guidefrequencyenable': '0', + 'hilink_dbho_enable': '1', + 'isdoublechip': '1', + 'maxapnum': '4', + 'maxassocoffloadon': None, + 'oledshowpassword': '1', + 'opennonewps_enable': '1', + 'paraimmediatework_enable': '1', + 'pmf_enable': '1', + 'show_maxassoc': '0', + 'showssid_enable': '0', + 'support_trigger_dualband_wps': '1', + 'wifi24g_switch_enable': '1', + 'wifi5g_enabled': '1', + 'wifi5gnamepostfix': '_5G', + 'wifi_chip_maxassoc': '32', + 'wifi_country_enable': '0', + 'wifi_dbdc_enable': '0', + 'wifi_dfs_enable': '0', + 'wifiautocountry_enabled': '0', + 'wifiguesttimeextendenable': '1', + 'wifimacfilterextendenable': '1', + 'wifimaxmacfilternum': '32', + 'wifishowradioswitch': '3', + 'wifispecialcharenable': '1', + 'wifiwpsmode': '0', + 'wifiwpssuportwepnone': '0', + 'wps_cancel_enable': '1', + 'wps_switch_enable': '1', + }), + 'wlan_wifi_guest_network_switch': dict({ + }), + }), + }) +# --- diff --git a/tests/components/huawei_lte/test_diagnostics.py b/tests/components/huawei_lte/test_diagnostics.py new file mode 100644 index 00000000000..e63ba94e9be --- /dev/null +++ b/tests/components/huawei_lte/test_diagnostics.py @@ -0,0 +1,38 @@ +"""Test huawei_lte diagnostics.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client_full + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_entry_diagnostics( + client, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + client.return_value = magic_client_full() + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, huawei_lte) + + assert result == snapshot(exclude=props("entry_id", "created_at", "modified_at")) From 42cf4e8db755fb43c3f8ffce14e8332e71f8006c Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 22 Jul 2025 13:42:40 +0800 Subject: [PATCH 010/235] Fix multiple webhook secrets for Telegram bot (#149103) --- homeassistant/components/telegram_bot/webhooks.py | 14 ++++++++++---- tests/components/telegram_bot/test_telegram_bot.py | 12 ++++++------ tests/components/telegram_bot/test_webhooks.py | 5 +++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 0bfad34681a..29c3305858b 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -82,7 +82,7 @@ class PushBot(BaseTelegramBot): self.base_url = config.data.get(CONF_URL) or get_url( hass, require_ssl=True, allow_internal=False ) - self.webhook_url = f"{self.base_url}{TELEGRAM_WEBHOOK_URL}" + self.webhook_url = self.base_url + _get_webhook_url(bot) async def shutdown(self) -> None: """Shutdown the app.""" @@ -98,9 +98,11 @@ class PushBot(BaseTelegramBot): api_kwargs={"secret_token": self.secret_token}, connect_timeout=5, ) - except TelegramError: + except TelegramError as err: retry_num += 1 - _LOGGER.warning("Error trying to set webhook (retry #%d)", retry_num) + _LOGGER.warning( + "Error trying to set webhook (retry #%d)", retry_num, exc_info=err + ) return False @@ -143,7 +145,6 @@ class PushBotView(HomeAssistantView): """View for handling webhook calls from Telegram.""" requires_auth = False - url = TELEGRAM_WEBHOOK_URL name = "telegram_webhooks" def __init__( @@ -160,6 +161,7 @@ class PushBotView(HomeAssistantView): self.application = application self.trusted_networks = trusted_networks self.secret_token = secret_token + self.url = _get_webhook_url(bot) async def post(self, request: HomeAssistantRequest) -> Response | None: """Accept the POST from telegram.""" @@ -183,3 +185,7 @@ class PushBotView(HomeAssistantView): await self.application.process_update(update) return None + + +def _get_webhook_url(bot: Bot) -> str: + return f"{TELEGRAM_WEBHOOK_URL}_{bot.id}" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 73dd9e27763..80b9859ceab 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -364,7 +364,7 @@ async def test_webhook_endpoint_generates_telegram_text_event( events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -391,7 +391,7 @@ async def test_webhook_endpoint_generates_telegram_command_event( events = async_capture_events(hass, "telegram_command") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_command, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -418,7 +418,7 @@ async def test_webhook_endpoint_generates_telegram_callback_event( events = async_capture_events(hass, "telegram_callback") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_callback_query, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -594,7 +594,7 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=unauthorized_update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -618,7 +618,7 @@ async def test_webhook_endpoint_without_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, ) assert response.status == 401 @@ -636,7 +636,7 @@ async def test_webhook_endpoint_invalid_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": incorrect_secret_token}, ) diff --git a/tests/components/telegram_bot/test_webhooks.py b/tests/components/telegram_bot/test_webhooks.py index 3419d33074d..a02bb3e3358 100644 --- a/tests/components/telegram_bot/test_webhooks.py +++ b/tests/components/telegram_bot/test_webhooks.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from telegram import WebhookInfo from telegram.error import TimedOut +from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -115,7 +116,7 @@ async def test_webhooks_update_invalid_json( client = await hass_client() response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) assert response.status == 400 @@ -139,7 +140,7 @@ async def test_webhooks_unauthorized_network( return_value=IPv4Network("1.2.3.4"), ) as mock_remote: response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", json="mock json", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) From 48b8827390502fa992e1bb28e74369bc82f5fe8d Mon Sep 17 00:00:00 2001 From: David Ferguson Date: Tue, 22 Jul 2025 02:56:54 -0400 Subject: [PATCH 011/235] Bump asyncsleepiq to 1.5.3 (#149215) --- 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 db29e5ab586..5082e2313df 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.5.2"] + "requirements": ["asyncsleepiq==1.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 074b68773da..710a7296850 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -542,7 +542,7 @@ asyncinotify==4.2.0 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.5.2 +asyncsleepiq==1.5.3 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10be4658356..c224132c2d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -500,7 +500,7 @@ async-upnp-client==0.45.0 asyncarve==0.1.1 # homeassistant.components.sleepiq -asyncsleepiq==1.5.2 +asyncsleepiq==1.5.3 # homeassistant.components.aurora auroranoaa==0.0.5 From 3e7974a63864f7ec37a360562e9810eece04e605 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 22 Jul 2025 08:58:32 +0200 Subject: [PATCH 012/235] Add missing hyphen to "post-processing" in `nzbget` (#149205) --- homeassistant/components/nzbget/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index 3b41e798d22..358be131c93 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -43,10 +43,10 @@ "name": "Disk free" }, "post_processing_jobs": { - "name": "Post processing jobs" + "name": "Post-processing jobs" }, "post_processing_paused": { - "name": "Post processing paused" + "name": "Post-processing paused" }, "queue_size": { "name": "Queue size" From df4e1411cc8041a3e7e9d91c3216df60568f4e40 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 22 Jul 2025 09:00:25 +0200 Subject: [PATCH 013/235] Bump uiprotect to version 7.18.1 (#149209) --- 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 e5b017e0ab6..5beb4ca059d 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.16.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.18.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 710a7296850..47e753be1f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.16.0 +uiprotect==7.18.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c224132c2d7..d74eb8270b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.16.0 +uiprotect==7.18.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 2315bcbfe3cff9f838113351f98adf9b124a16b3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:02:15 +0200 Subject: [PATCH 014/235] Set has_entity_name in Onkyo (#149223) --- homeassistant/components/onkyo/media_player.py | 1 + homeassistant/components/onkyo/quality_scale.yaml | 2 +- tests/components/onkyo/snapshots/test_media_player.ambr | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 2965388236d..05374bfe6cf 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -152,6 +152,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """Onkyo Receiver Media Player (one per each zone).""" _attr_should_poll = False + _attr_has_entity_name = True _supports_volume: bool = False # None means no technical possibility of support diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index caf0d33fafc..1e8bf07e66a 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -22,7 +22,7 @@ rules: comment: | Currently we store created entities in hass.data. That should be removed in the future. entity-unique-id: done - has-entity-name: todo + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done diff --git a/tests/components/onkyo/snapshots/test_media_player.ambr b/tests/components/onkyo/snapshots/test_media_player.ambr index 1504952a86d..32717a8af43 100644 --- a/tests/components/onkyo/snapshots/test_media_player.ambr +++ b/tests/components/onkyo/snapshots/test_media_player.ambr @@ -22,7 +22,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.tx_nr7100', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -98,7 +98,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.tx_nr7100_zone_2', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -162,7 +162,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.tx_nr7100_zone_3', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , From f5d68a4ea40e76358ea29d09839d093cdfd8c9c7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:10:59 +0200 Subject: [PATCH 015/235] Simplify getting domains to resolve in bootstrap (#145829) --- homeassistant/bootstrap.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 493b9b1eab6..4e49d6cec7e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -695,10 +695,10 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" - # Filter out the repeating and common config section [homeassistant] - domains = { - domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN - } + # The common config section [homeassistant] could be filtered here, + # but that is not necessary, since it corresponds to the core integration, + # that is always unconditionally loaded. + domains = {cv.domain_key(key) for key in config} # Add config entry and default domains if not hass.config.recovery_mode: @@ -726,34 +726,28 @@ async def _async_resolve_domains_and_preload( together with all their dependencies. """ domains_to_setup = _get_domains(hass, config) - platform_integrations = conf_util.extract_platform_integrations( - config, BASE_PLATFORMS - ) - # Ensure base platforms that have platform integrations are added to `domains`, - # so they can be setup first instead of discovering them later when a config - # entry setup task notices that it's needed and there is already a long line - # to use the import executor. + + # Also process all base platforms since we do not require the manifest + # to list them as dependencies. + # We want to later avoid lock contention when multiple integrations try to load + # their manifests at once. # + # Additionally process integrations that are defined under base platforms + # to speed things up. # For example if we have # sensor: # - platform: template # - # `template` has to be loaded to validate the config for sensor - # so we want to start loading `sensor` as soon as we know - # it will be needed. The more platforms under `sensor:`, the longer + # `template` has to be loaded to validate the config for sensor. + # The more platforms under `sensor:`, the longer # it will take to finish setup for `sensor` because each of these # platforms has to be imported before we can validate the config. # # Thankfully we are migrating away from the platform pattern # so this will be less of a problem in the future. - domains_to_setup.update(platform_integrations) - - # Additionally process base platforms since we do not require the manifest - # to list them as dependencies. - # We want to later avoid lock contention when multiple integrations try to load - # their manifests at once. - # Also process integrations that are defined under base platforms - # to speed things up. + platform_integrations = conf_util.extract_platform_integrations( + config, BASE_PLATFORMS + ) additional_domains_to_process = { *BASE_PLATFORMS, *chain.from_iterable(platform_integrations.values()), From 8d1c789ca2c2e291bc9c60afe9b9f8275c7b9306 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:10:23 +0200 Subject: [PATCH 016/235] Replace RuntimeError with TYPE_CHECKING in Tuya (#149227) --- homeassistant/components/tuya/climate.py | 17 ++- homeassistant/components/tuya/cover.py | 16 ++- tests/components/tuya/test_climate.py | 60 ++++++++++ tests/components/tuya/test_cover.py | 137 +++++++++++++++++++++++ 4 files changed, 211 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index d8907b0db9d..370548d67b0 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -307,17 +307,16 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if TYPE_CHECKING: - # We can rely on supported_features from __init__ + # guarded by ClimateEntityFeature.FAN_MODE assert self._fan_mode_dp_code is not None self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self._set_humidity is None: - raise RuntimeError( - "Cannot set humidity, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by ClimateEntityFeature.TARGET_HUMIDITY + assert self._set_humidity is not None self._send_command( [ @@ -355,11 +354,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if self._set_temperature is None: - raise RuntimeError( - "Cannot set target temperature, device doesn't provide methods to" - " set it" - ) + if TYPE_CHECKING: + # guarded by ClimateEntityFeature.TARGET_TEMPERATURE + assert self._set_temperature is not None self._send_command( [ diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index a385a35d903..205a65431dd 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from tuya_sharing import CustomerDevice, Manager @@ -333,10 +333,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if self._set_position is None: - raise RuntimeError( - "Cannot set position, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by CoverEntityFeature.SET_POSITION + assert self._set_position is not None self._send_command( [ @@ -364,10 +363,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - if self._tilt is None: - raise RuntimeError( - "Cannot set tilt, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by CoverEntityFeature.SET_TILT_POSITION + assert self._tilt is not None self._send_command( [ diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index 9c0e3c31a26..e8aee3f4f96 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -11,6 +11,8 @@ from tuya_sharing import CustomerDevice from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_TEMPERATURE, ) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform @@ -62,6 +64,36 @@ async def test_platform_setup_no_discovery( ) +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_set_temperature( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set temperature service.""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": entity_id, + "temperature": 22.7, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "temp_set", "value": 22}] + ) + + @pytest.mark.parametrize( "mock_device_code", ["kt_serenelife_slpac905wuk_air_conditioner"], @@ -125,3 +157,31 @@ async def test_fan_mode_no_valid_code( }, blocking=True, ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_set_humidity_not_supported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service (not available on this device).""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + blocking=True, + ) diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 29a6d65978f..24e43dcccec 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -8,9 +8,17 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -57,6 +65,107 @@ async def test_platform_setup_no_discovery( ) +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_open_service( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test open service.""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "control", "value": "open"}, + {"code": "percent_control", "value": 0}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_close_service( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test close service.""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "control", "value": "close"}, + {"code": "percent_control", "value": 100}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_set_position( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set position service (not available on this device).""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + { + "entity_id": entity_id, + "position": 25, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "percent_control", "value": 75}, + ], + ) + + @pytest.mark.parametrize( "mock_device_code", ["cl_am43_corded_motor_zigbee_cover"], @@ -89,3 +198,31 @@ async def test_percent_state_on_cover( state = hass.states.get(entity_id) assert state is not None, f"{entity_id} does not exist" assert state.attributes["current_position"] == percent_state + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_set_tilt_position_not_supported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set tilt position service (not available on this device).""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + { + "entity_id": entity_id, + "tilt_position": 50, + }, + blocking=True, + ) From 1f07dd7946388f8b9b54a1b90ef37a615b9f7aa8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:07:56 +0200 Subject: [PATCH 017/235] Bump github/codeql-action from 3.29.2 to 3.29.3 (#149220) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8a0af8bd5f9..cbc343b9d98 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.2 + uses: github/codeql-action/init@v3.29.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.2 + uses: github/codeql-action/analyze@v3.29.3 with: category: "/language:python" From e79d42ecfc6e96b2fed08cdbc4496b4f38700a33 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 22 Jul 2025 13:32:45 +0200 Subject: [PATCH 018/235] Add missing hyphen to "post-heater" in `vallox` (#149222) --- homeassistant/components/vallox/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 2a074cf2015..f12a5328330 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -34,7 +34,7 @@ "entity": { "binary_sensor": { "post_heater": { - "name": "Post heater" + "name": "Post-heater" } }, "number": { From 49807c9fbe504b121f1254bb30fab7e62447e379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 22 Jul 2025 13:33:03 +0200 Subject: [PATCH 019/235] Add set_program service to Miele (#143442) --- homeassistant/components/miele/__init__.py | 13 ++- homeassistant/components/miele/icons.json | 5 + homeassistant/components/miele/services.py | 92 ++++++++++++++++ homeassistant/components/miele/services.yaml | 17 +++ homeassistant/components/miele/strings.json | 22 ++++ tests/components/miele/conftest.py | 1 + tests/components/miele/test_services.py | 110 +++++++++++++++++++ 7 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/miele/services.py create mode 100644 homeassistant/components/miele/services.yaml create mode 100644 tests/components/miele/test_services.py diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 9b9ec81bea9..1cb2fc0fab1 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -7,16 +7,18 @@ from aiohttp import ClientError, ClientResponseError from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import DOMAIN from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -29,6 +31,15 @@ PLATFORMS: list[Platform] = [ Platform.VACUUM, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up service actions.""" + await async_setup_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: """Set up Miele from a config entry.""" diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 44b51a67c24..1b757a9e113 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -103,5 +103,10 @@ "default": "mdi:snowflake" } } + }, + "services": { + "set_program": { + "service": "mdi:arrow-right-circle-outline" + } } } diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py new file mode 100644 index 00000000000..70ea20ccc4a --- /dev/null +++ b/homeassistant/components/miele/services.py @@ -0,0 +1,92 @@ +"""Services for Miele integration.""" + +import logging +from typing import cast + +import aiohttp +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .const import DOMAIN +from .coordinator import MieleConfigEntry + +ATTR_PROGRAM_ID = "program_id" +ATTR_DURATION = "duration" + + +SERVICE_SET_PROGRAM = "set_program" +SERVICE_SET_PROGRAM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM_ID): cv.positive_int, + }, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry: + """Extract config entry from the service call.""" + hass = service_call.hass + target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entries: list[MieleConfigEntry] = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + return target_entries[0] + + +async def set_program(call: ServiceCall) -> None: + """Set a program on a Miele appliance.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + device_reg = dr.async_get(call.hass) + api = config_entry.runtime_data.api + device = call.data[ATTR_DEVICE_ID] + device_entry = device_reg.async_get(device) + + data = {"programId": call.data[ATTR_PROGRAM_ID]} + serial_number = next( + ( + identifier[1] + for identifier in cast(dr.DeviceEntry, device_entry).identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if serial_number is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + try: + await api.set_program(serial_number, data) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_program_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + + hass.services.async_register( + DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA + ) diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml new file mode 100644 index 00000000000..486fdf7307b --- /dev/null +++ b/homeassistant/components/miele/services.yaml @@ -0,0 +1,17 @@ +# Services descriptions for Miele integration + +set_program: + fields: + device_id: + selector: + device: + integration: miele + required: true + program_id: + required: true + selector: + number: + min: 0 + max: 99999 + mode: box + example: 24 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 97035da6d5f..865f3313ad5 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1059,8 +1059,30 @@ "config_entry_not_ready": { "message": "Error while loading the integration." }, + "invalid_target": { + "message": "Invalid device targeted." + }, + "set_program_error": { + "message": "'Set program' action failed {status} / {message}." + }, "set_state_error": { "message": "Failed to set state for {entity}." } + }, + "services": { + "set_program": { + "name": "Set program", + "description": "Sets and starts a program on the appliance.", + "fields": { + "device_id": { + "description": "The device to set the program on.", + "name": "Device" + }, + "program_id": { + "description": "The ID of the program to set.", + "name": "Program ID" + } + } + } } } diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 94112e29143..7b3c3f35f7e 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -125,6 +125,7 @@ def mock_miele_client( client.get_devices.return_value = device_fixture client.get_actions.return_value = action_fixture client.get_programs.return_value = programs_fixture + client.set_program.return_value = None yield client diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py new file mode 100644 index 00000000000..8b33c17d69f --- /dev/null +++ b/tests/components/miele/test_services.py @@ -0,0 +1,110 @@ +"""Tests the services provided by the miele integration.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from voluptuous import MultipleInvalid + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_APPLIANCE = "Dummy_Appliance_1" + + +async def test_services( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + { + ATTR_DEVICE_ID: device.id, + ATTR_PROGRAM_ID: 24, + }, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 24} + ) + + +async def test_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.set_program.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match="'Set program' action failed"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id, ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 1} + ) + + +async def test_service_validation_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services handle bad data.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test missing program_id + with pytest.raises(MultipleInvalid, match="required key not provided"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid program_id + with pytest.raises(MultipleInvalid, match="expected int for dictionary value"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id, ATTR_PROGRAM_ID: "invalid"}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid device + with pytest.raises(ServiceValidationError, match="Invalid device targeted"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": "invalid_device", ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() From e5c7e04329a324fbefe979796612101b2349774c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 22 Jul 2025 13:43:41 +0200 Subject: [PATCH 020/235] Introduce base entity in Open Router (#148910) --- homeassistant/components/open_router/const.py | 3 +- .../components/open_router/conversation.py | 174 +--------------- .../components/open_router/entity.py | 185 ++++++++++++++++++ .../components/open_router/strings.json | 2 +- 4 files changed, 195 insertions(+), 169 deletions(-) create mode 100644 homeassistant/components/open_router/entity.py diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py index 9fbce10da4e..7316d45c3e5 100644 --- a/homeassistant/components/open_router/const.py +++ b/homeassistant/components/open_router/const.py @@ -2,13 +2,12 @@ import logging -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT from homeassistant.helpers import llm DOMAIN = "open_router" LOGGER = logging.getLogger(__package__) -CONF_PROMPT = "prompt" CONF_RECOMMENDED = "recommended" RECOMMENDED_CONVERSATION_OPTIONS = { diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index 06196565aad..826931d3da7 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -1,39 +1,16 @@ """Conversation support for OpenRouter.""" -from collections.abc import AsyncGenerator, Callable -import json -from typing import Any, Literal - -import openai -from openai import NOT_GIVEN -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionMessage, - ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, - ChatCompletionSystemMessageParam, - ChatCompletionToolMessageParam, - ChatCompletionToolParam, - ChatCompletionUserMessageParam, -) -from openai.types.chat.chat_completion_message_tool_call_param import Function -from openai.types.shared_params import FunctionDefinition -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry -from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import llm -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenRouterConfigEntry -from .const import CONF_PROMPT, DOMAIN, LOGGER - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 +from .const import DOMAIN +from .entity import OpenRouterEntity async def async_setup_entry( @@ -49,106 +26,14 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, - custom_serializer: Callable[[Any], Any] | None, -) -> ChatCompletionToolParam: - """Format tool specification.""" - tool_spec = FunctionDefinition( - name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), - ) - if tool.description: - tool_spec["description"] = tool.description - return ChatCompletionToolParam(type="function", function=tool_spec) - - -def _convert_content_to_chat_message( - content: conversation.Content, -) -> ChatCompletionMessageParam | None: - """Convert any native chat message for this agent to the native format.""" - LOGGER.debug("_convert_content_to_chat_message=%s", content) - if isinstance(content, conversation.ToolResultContent): - return ChatCompletionToolMessageParam( - role="tool", - tool_call_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - - role: Literal["user", "assistant", "system"] = content.role - if role == "system" and content.content: - return ChatCompletionSystemMessageParam(role="system", content=content.content) - - if role == "user" and content.content: - return ChatCompletionUserMessageParam(role="user", content=content.content) - - if role == "assistant": - param = ChatCompletionAssistantMessageParam( - role="assistant", - content=content.content, - ) - if isinstance(content, conversation.AssistantContent) and content.tool_calls: - param["tool_calls"] = [ - ChatCompletionMessageToolCallParam( - type="function", - id=tool_call.id, - function=Function( - arguments=json.dumps(tool_call.tool_args), - name=tool_call.tool_name, - ), - ) - for tool_call in content.tool_calls - ] - return param - LOGGER.warning("Could not convert message to Completions API: %s", content) - return None - - -def _decode_tool_arguments(arguments: str) -> Any: - """Decode tool call arguments.""" - try: - return json.loads(arguments) - except json.JSONDecodeError as err: - raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err - - -async def _transform_response( - message: ChatCompletionMessage, -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the OpenRouter message to a ChatLog format.""" - data: conversation.AssistantContentDeltaDict = { - "role": message.role, - "content": message.content, - } - if message.tool_calls: - data["tool_calls"] = [ - llm.ToolInput( - id=tool_call.id, - tool_name=tool_call.function.name, - tool_args=_decode_tool_arguments(tool_call.function.arguments), - ) - for tool_call in message.tool_calls - ] - yield data - - -class OpenRouterConversationEntity(conversation.ConversationEntity): +class OpenRouterConversationEntity(OpenRouterEntity, conversation.ConversationEntity): """OpenRouter conversation agent.""" - _attr_has_entity_name = True _attr_name = None def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self.subentry = subentry - self.model = subentry.data[CONF_MODEL] - self._attr_unique_id = subentry.subentry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, subentry.subentry_id)}, - name=subentry.title, - entry_type=DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -164,7 +49,7 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): user_input: conversation.ConversationInput, chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: - """Process a sentence.""" + """Process the user input and call the API.""" options = self.subentry.data try: @@ -177,49 +62,6 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): except conversation.ConverseError as err: return err.as_conversation_result() - tools: list[ChatCompletionToolParam] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - messages = [ - m - for content in chat_log.content - if (m := _convert_content_to_chat_message(content)) - ] - - client = self.entry.runtime_data - - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - result = await client.chat.completions.create( - model=self.model, - messages=messages, - tools=tools or NOT_GIVEN, - user=chat_log.conversation_id, - extra_headers={ - "X-Title": "Home Assistant", - "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", - }, - ) - except openai.OpenAIError as err: - LOGGER.error("Error talking to API: %s", err) - raise HomeAssistantError("Error talking to API") from err - - result_message = result.choices[0].message - - messages.extend( - [ - msg - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_response(result_message) - ) - if (msg := _convert_content_to_chat_message(content)) - ] - ) - if not chat_log.unresponded_tool_results: - break + await self._async_handle_chat_log(chat_log) return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py new file mode 100644 index 00000000000..e706656d377 --- /dev/null +++ b/homeassistant/components/open_router/entity.py @@ -0,0 +1,185 @@ +"""Base entity for Open Router.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal + +import openai +from openai import NOT_GIVEN +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionToolParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OpenRouterConfigEntry +from .const import DOMAIN, LOGGER + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenRouter message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + ] + yield data + + +class OpenRouterEntity(Entity): + """Base entity for Open Router.""" + + _attr_has_entity_name = True + + def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None: + """Generate an answer for the chat log.""" + + tools: list[ChatCompletionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + client = self.entry.runtime_data + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools or NOT_GIVEN, + user=chat_log.conversation_id, + extra_headers={ + "X-Title": "Home Assistant", + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + }, + ) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + result_message = result.choices[0].message + + messages.extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index 6e6674dac06..91c4cc350ae 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -25,7 +25,7 @@ "description": "Configure the new conversation agent", "data": { "model": "Model", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" }, "data_description": { From c075134845d538e97002d6c89db2b3e092a9dfca Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 22 Jul 2025 13:58:33 +0200 Subject: [PATCH 021/235] Use OpenRouterClient to get the models (#148903) --- .../components/open_router/config_flow.py | 24 +++-- tests/components/open_router/conftest.py | 29 ++---- .../open_router/fixtures/models.json | 92 +++++++++++++++++++ .../open_router/test_config_flow.py | 14 +-- .../open_router/test_conversation.py | 2 +- 5 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 tests/components/open_router/fixtures/models.json diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py index e228492e3a1..96f3769575b 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -5,8 +5,7 @@ from __future__ import annotations import logging from typing import Any -from openai import AsyncOpenAI -from python_open_router import OpenRouterClient, OpenRouterError +from python_open_router import Model, OpenRouterClient, OpenRouterError import voluptuous as vol from homeassistant.config_entries import ( @@ -20,7 +19,6 @@ from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import callback from homeassistant.helpers import llm from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -85,7 +83,7 @@ class ConversationFlowHandler(ConfigSubentryFlow): def __init__(self) -> None: """Initialize the subentry flow.""" - self.options: dict[str, str] = {} + self.models: dict[str, Model] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -95,14 +93,18 @@ class ConversationFlowHandler(ConfigSubentryFlow): if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) return self.async_create_entry( - title=self.options[user_input[CONF_MODEL]], data=user_input + title=self.models[user_input[CONF_MODEL]].name, data=user_input ) entry = self._get_entry() - client = AsyncOpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=entry.data[CONF_API_KEY], - http_client=get_async_client(self.hass), + client = OpenRouterClient( + entry.data[CONF_API_KEY], async_get_clientsession(self.hass) ) + models = await client.get_models() + self.models = {model.id: model for model in models} + options = [ + SelectOptionDict(value=model.id, label=model.name) for model in models + ] + hass_apis: list[SelectOptionDict] = [ SelectOptionDict( label=api.name, @@ -110,10 +112,6 @@ class ConversationFlowHandler(ConfigSubentryFlow): ) for api in llm.async_get_apis(self.hass) ] - options = [] - async for model in client.with_options(timeout=10.0).models.list(): - options.append(SelectOptionDict(value=model.id, label=model.name)) # type: ignore[attr-defined] - self.options[model.id] = model.name # type: ignore[attr-defined] return self.async_show_form( step_id="user", data_schema=vol.Schema( diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py index ca679c2ebef..7bb967f369f 100644 --- a/tests/components/open_router/conftest.py +++ b/tests/components/open_router/conftest.py @@ -3,12 +3,13 @@ from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch from openai.types import CompletionUsage from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat.chat_completion import Choice import pytest +from python_open_router import ModelsDataWrapper from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN from homeassistant.config_entries import ConfigSubentryData @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_fixture @pytest.fixture @@ -40,7 +41,7 @@ def enable_assist() -> bool: def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: """Mock conversation subentry data.""" res: dict[str, Any] = { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "You are a helpful assistant.", } if enable_assist: @@ -82,24 +83,8 @@ class Model: @pytest.fixture async def mock_openai_client() -> AsyncGenerator[AsyncMock]: """Initialize integration.""" - with ( - patch("homeassistant.components.open_router.AsyncOpenAI") as mock_client, - patch( - "homeassistant.components.open_router.config_flow.AsyncOpenAI", - new=mock_client, - ), - ): + with patch("homeassistant.components.open_router.AsyncOpenAI") as mock_client: client = mock_client.return_value - client.with_options = MagicMock() - client.with_options.return_value.models = MagicMock() - client.with_options.return_value.models.list.return_value = ( - get_generator_from_data( - [ - Model(id="gpt-4", name="GPT-4"), - Model(id="gpt-3.5-turbo", name="GPT-3.5 Turbo"), - ], - ) - ) client.chat.completions.create = AsyncMock( return_value=ChatCompletion( id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", @@ -128,13 +113,15 @@ async def mock_openai_client() -> AsyncGenerator[AsyncMock]: @pytest.fixture -async def mock_open_router_client() -> AsyncGenerator[AsyncMock]: +async def mock_open_router_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: """Initialize integration.""" with patch( "homeassistant.components.open_router.config_flow.OpenRouterClient", autospec=True, ) as mock_client: client = mock_client.return_value + models = await async_load_fixture(hass, "models.json", DOMAIN) + client.get_models.return_value = ModelsDataWrapper.from_json(models).data yield client diff --git a/tests/components/open_router/fixtures/models.json b/tests/components/open_router/fixtures/models.json new file mode 100644 index 00000000000..0a35686094e --- /dev/null +++ b/tests/components/open_router/fixtures/models.json @@ -0,0 +1,92 @@ +{ + "data": [ + { + "id": "openai/gpt-3.5-turbo", + "canonical_slug": "openai/gpt-3.5-turbo", + "hugging_face_id": null, + "name": "OpenAI: GPT-3.5 Turbo", + "created": 1695859200, + "description": "This model is a variant of GPT-3.5 Turbo tuned for instructional prompts and omitting chat-related optimizations. Training data: up to Sep 2021.", + "context_length": 4095, + "architecture": { + "modality": "text->text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "GPT", + "instruct_type": "chatml" + }, + "pricing": { + "prompt": "0.0000015", + "completion": "0.000002", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0" + }, + "top_provider": { + "context_length": 4095, + "max_completion_tokens": 4096, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", + "response_format" + ] + }, + { + "id": "openai/gpt-4", + "canonical_slug": "openai/gpt-4", + "hugging_face_id": null, + "name": "OpenAI: GPT-4", + "created": 1685232000, + "description": "OpenAI's flagship model, GPT-4 is a large-scale multimodal language model capable of solving difficult problems with greater accuracy than previous models due to its broader general knowledge and advanced reasoning capabilities. Training data: up to Sep 2021.", + "context_length": 8191, + "architecture": { + "modality": "text->text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": "0.00003", + "completion": "0.00006", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0" + }, + "top_provider": { + "context_length": 8191, + "max_completion_tokens": 4096, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "tools", + "tool_choice", + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", + "response_format" + ] + } + ] +} diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py index 5e7a67d4a2b..0720f6d90f5 100644 --- a/tests/components/open_router/test_config_flow.py +++ b/tests/components/open_router/test_config_flow.py @@ -124,13 +124,14 @@ async def test_create_conversation_agent( assert result["step_id"] == "user" assert result["data_schema"].schema["model"].config["options"] == [ - {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + {"value": "openai/gpt-3.5-turbo", "label": "OpenAI: GPT-3.5 Turbo"}, + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, ] result = await hass.config_entries.subentries.async_configure( result["flow_id"], { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: ["assist"], }, @@ -138,7 +139,7 @@ async def test_create_conversation_agent( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: ["assist"], } @@ -165,13 +166,14 @@ async def test_create_conversation_agent_no_control( assert result["step_id"] == "user" assert result["data_schema"].schema["model"].config["options"] == [ - {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + {"value": "openai/gpt-3.5-turbo", "label": "OpenAI: GPT-3.5 Turbo"}, + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, ] result = await hass.config_entries.subentries.async_configure( result["flow_id"], { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: [], }, @@ -179,6 +181,6 @@ async def test_create_conversation_agent_no_control( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", } diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index 84742191efd..93f8264801a 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -65,7 +65,7 @@ async def test_default_prompt( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert mock_chat_log.content[1:] == snapshot call = mock_openai_client.chat.completions.create.call_args_list[0][1] - assert call["model"] == "gpt-3.5-turbo" + assert call["model"] == "openai/gpt-3.5-turbo" assert call["extra_headers"] == { "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", "X-Title": "Home Assistant", From 3f67ba4c02e3dffe19fed36aa0203d856c146915 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:06:03 +0200 Subject: [PATCH 022/235] Add support for ELV-SH-WSM to homematicip (#149098) --- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/sensor.py | 83 ++++++++++ .../components/homematicip_cloud/valve.py | 59 +++++++ .../fixtures/homematicip_cloud.json | 155 ++++++++++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_sensor.py | 65 ++++++++ .../homematicip_cloud/test_valve.py | 35 ++++ 7 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homematicip_cloud/valve.py create mode 100644 tests/components/homematicip_cloud/test_valve.py diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 2b72794b323..d4c0b1a45ca 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.LOCK, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, Platform.WEATHER, ] diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 95de7f15af0..1ed483b86ad 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -33,6 +33,7 @@ from homematicip.device import ( TemperatureHumiditySensorOutdoor, TemperatureHumiditySensorWithoutDisplay, TiltVibrationSensor, + WateringActuator, WeatherSensor, WeatherSensorPlus, WeatherSensorPro, @@ -167,6 +168,29 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]: HomematicipTiltStateSensor(hap, device), HomematicipTiltAngleSensor(hap, device), ], + WateringActuator: lambda device: [ + entity + for ch in device.functionalChannels + if ch.functionalChannelType + == FunctionalChannelType.WATERING_ACTUATOR_CHANNEL + for entity in ( + HomematicipWaterFlowSensor( + hap, device, channel=ch.index, post="currentWaterFlow" + ), + HomematicipWaterVolumeSensor( + hap, + device, + channel=ch.index, + post="waterVolume", + attribute="waterVolume", + ), + HomematicipWaterVolumeSinceOpenSensor( + hap, + device, + channel=ch.index, + ), + ) + ], WeatherSensor: lambda device: [ HomematicipTemperatureSensor(hap, device), HomematicipHumiditySensor(hap, device), @@ -267,6 +291,65 @@ async def async_setup_entry( async_add_entities(entities) +class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP watering flow sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolumeFlowRate.LITERS_PER_MINUTE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, hap: HomematicipHAP, device: Device, channel: int, post: str + ) -> None: + """Initialize the watering flow sensor device.""" + super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + + @property + def native_value(self) -> float | None: + """Return the state.""" + return self.functional_channel.waterFlow + + +class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP watering volume sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolume.LITERS + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__( + self, + hap: HomematicipHAP, + device: Device, + channel: int, + post: str, + attribute: str, + ) -> None: + """Initialize the watering volume sensor device.""" + super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + self._attribute_name = attribute + + @property + def native_value(self) -> float | None: + """Return the state.""" + return getattr(self.functional_channel, self._attribute_name, None) + + +class HomematicipWaterVolumeSinceOpenSensor(HomematicipWaterVolumeSensor): + """Representation of the HomematicIP watering volume since open sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolume.LITERS + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the watering flow volume since open device.""" + super().__init__( + hap, + device, + channel=channel, + post="waterVolumeSinceOpen", + attribute="waterVolumeSinceOpen", + ) + + class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP tilt angle sensor.""" diff --git a/homeassistant/components/homematicip_cloud/valve.py b/homeassistant/components/homematicip_cloud/valve.py new file mode 100644 index 00000000000..aaeaa3c565c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/valve.py @@ -0,0 +1,59 @@ +"""Support for HomematicIP Cloud valve devices.""" + +from homematicip.base.functionalChannels import FunctionalChannelType +from homematicip.device import Device + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import HomematicipGenericEntity +from .hap import HomematicIPConfigEntry, HomematicipHAP + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomematicIPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the HomematicIP valves from a config entry.""" + hap = config_entry.runtime_data + entities = [ + HomematicipWateringValve(hap, device, ch.index) + for device in hap.home.devices + for ch in device.functionalChannels + if ch.functionalChannelType == FunctionalChannelType.WATERING_ACTUATOR_CHANNEL + ] + + async_add_entities(entities) + + +class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity): + """Representation of a HomematicIP valve.""" + + _attr_reports_position = False + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_device_class = ValveDeviceClass.WATER + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the valve.""" + super().__init__( + hap, device=device, channel=channel, post="watering", is_multi_channel=True + ) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.functional_channel.set_watering_switch_state_async(True) + + async def async_close_valve(self) -> None: + """Close valve.""" + await self.functional_channel.set_watering_switch_state_async(False) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return self.functional_channel.wateringActive is False diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index c9eab0cf4f5..44d8cc33c80 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8936,6 +8936,161 @@ "serializedGlobalTradeItemNumber": "3014F71100000000000RGBW2", "type": "RGBW_DIMMER", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000SHWSM": { + "availableFirmwareVersion": "0.0.0", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.10", + "firmwareVersionInteger": 65546, + "functionalChannels": { + "0": { + "altitude": null, + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "dataDecodingFailedError": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000SHWSM", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "frostProtectionError": false, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000022"], + "index": 0, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -46, + "rssiPeerValue": -43, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDataDecodingFailedError": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceMountingModuleError": false, + "IFeatureDeviceOverheated": true, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTempSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": true, + "IFeatureMulticastRouter": false, + "IFeatureNoDataFromLinkyError": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IFeatureTicVersionError": false, + "IOptionalFeatureAltitude": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceFrostProtectionError": true, + "IOptionalFeatureDeviceInputLayoutMode": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDeviceSwitchChannelMode": false, + "IOptionalFeatureDeviceValveError": true, + "IOptionalFeatureDeviceWaterError": true, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "temperatureSensorError": null, + "ticVersionError": null, + "unreach": false, + "valveFlowError": false, + "valveWaterError": false + }, + "1": { + "channelRole": "WATERING_ACTUATOR", + "deviceId": "3014F71100000000000SHWSM", + "functionalChannelType": "WATERING_ACTUATOR_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000023"], + "index": 1, + "label": "", + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": { + "IFeatureWateringGroupActuatorChannel": true, + "IFeatureWateringProfileActuatorChannel": true + }, + "userDesiredProfileMode": "AUTOMATIC", + "waterFlow": 12.0, + "waterVolume": 455.0, + "waterVolumeSinceOpen": 67.0, + "wateringActive": false, + "wateringOnTime": 3600.0 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000SHWSM", + "label": "Bewaesserungsaktor", + "lastStatusUpdate": 1749501203047, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 9, + "measuredAttributes": {}, + "modelId": 586, + "modelType": "ELV-SH-WSM", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F71100000000000SHWSM", + "type": "WATERING_ACTUATOR", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 4fb9f9eede8..8bff1798255 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 335 + assert len(mock_hap.hmip_device_by_entity_id) == 340 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index a107214b373..77e90ccaff6 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -35,6 +35,8 @@ from homeassistant.const import ( UnitOfPower, UnitOfSpeed, UnitOfTemperature, + UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant @@ -796,3 +798,66 @@ async def test_hmip_absolute_humidity_sensor_invalid_value( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNKNOWN + + +async def test_hmip_water_valve_current_water_flow( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipCurrentWaterFlow.""" + entity_id = "sensor.bewaesserungsaktor_currentwaterflow" + entity_name = "Bewaesserungsaktor currentWaterFlow" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "12.0" + assert ( + ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + == UnitOfVolumeFlowRate.LITERS_PER_MINUTE + ) + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + +async def test_hmip_water_valve_water_volume( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipWaterVolume.""" + entity_id = "sensor.bewaesserungsaktor_watervolume" + entity_name = "Bewaesserungsaktor waterVolume" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "455.0" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfVolume.LITERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING + + +async def test_hmip_water_valve_water_volume_since_open( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipWaterVolumeSinceOpen.""" + entity_id = "sensor.bewaesserungsaktor_watervolumesinceopen" + entity_name = "Bewaesserungsaktor waterVolumeSinceOpen" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "67.0" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfVolume.LITERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/homematicip_cloud/test_valve.py b/tests/components/homematicip_cloud/test_valve.py new file mode 100644 index 00000000000..5c2840dc28f --- /dev/null +++ b/tests/components/homematicip_cloud/test_valve.py @@ -0,0 +1,35 @@ +"""Test HomematicIP Cloud valve entities.""" + +from homeassistant.components.valve import SERVICE_OPEN_VALVE, ValveState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics + + +async def test_watering_valve( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicIP watering valve.""" + entity_id = "valve.bewaesserungsaktor_watering" + entity_name = "Bewaesserungsaktor watering" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == ValveState.CLOSED + + await hass.services.async_call( + Platform.VALVE, SERVICE_OPEN_VALVE, {"entity_id": entity_id}, blocking=True + ) + + await async_manipulate_test_data( + hass, hmip_device, "wateringActive", True, channel=1 + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == ValveState.OPEN From 5a771b501d3f2aed01e91ade9ab41d5c9979897c Mon Sep 17 00:00:00 2001 From: wedsa5 Date: Tue, 22 Jul 2025 06:07:34 -0600 Subject: [PATCH 023/235] Fix ColorMode.WHITE support in Tuya (#126242) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Erik Montnemery --- homeassistant/components/tuya/light.py | 36 ++++++---- .../components/tuya/snapshots/test_light.ambr | 20 ++---- tests/components/tuya/test_light.py | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index b6d0332e03a..698ca302310 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_WHITE, ColorMode, LightEntity, LightEntityDescription, @@ -488,6 +489,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _color_data_type: ColorTypeData | None = None _color_mode: DPCode | None = None _color_temp: IntegerTypeData | None = None + _white_color_mode = ColorMode.COLOR_TEMP _fixed_color_mode: ColorMode | None = None _attr_min_color_temp_kelvin = 2000 # 500 Mireds _attr_max_color_temp_kelvin = 6500 # 153 Mireds @@ -526,6 +528,13 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ): self._color_temp = int_type color_modes.add(ColorMode.COLOR_TEMP) + # If entity does not have color_temp, check if it has work_mode "white" + elif color_mode_enum := self.find_dpcode( + description.color_mode, dptype=DPType.ENUM, prefer_function=True + ): + if WorkMode.WHITE.value in color_mode_enum.range: + color_modes.add(ColorMode.WHITE) + self._white_color_mode = ColorMode.WHITE if ( dpcode := self.find_dpcode(description.color_data, prefer_function=True) @@ -566,15 +575,17 @@ class TuyaLightEntity(TuyaEntity, LightEntity): """Turn on or control the light.""" commands = [{"code": self.entity_description.key, "value": True}] - if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: - if self._color_mode_dpcode: - commands += [ - { - "code": self._color_mode_dpcode, - "value": WorkMode.WHITE, - }, - ] + if self._color_mode_dpcode and ( + ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs + ): + commands += [ + { + "code": self._color_mode_dpcode, + "value": WorkMode.WHITE, + }, + ] + if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: commands += [ { "code": self._color_temp.dpcode, @@ -596,6 +607,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): or ( ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS + and ATTR_WHITE not in kwargs and ATTR_COLOR_TEMP_KELVIN not in kwargs ) ): @@ -755,15 +767,15 @@ class TuyaLightEntity(TuyaEntity, LightEntity): # The light supports only a single color mode, return it return self._fixed_color_mode - # The light supports both color temperature and HS, determine which mode the - # light is in. We consider it to be in HS color mode, when work mode is anything - # else than "white". + # The light supports both white (with or without adjustable color temperature) + # and HS, determine which mode the light is in. We consider it to be in HS color + # mode, when work mode is anything else than "white". if ( self._color_mode_dpcode and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE ): return ColorMode.HS - return ColorMode.COLOR_TEMP + return self._white_color_mode def _get_color_data(self) -> ColorData | None: """Get current color data from device.""" diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index c691aae2cc1..5fcf58dda6d 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -64,6 +64,7 @@ 'capabilities': dict({ 'supported_color_modes': list([ , + , ]), }), 'config_entry_id': , @@ -99,25 +100,16 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': 138, - 'color_mode': , + 'color_mode': , 'friendly_name': 'Garage light', - 'hs_color': tuple( - 243.0, - 86.0, - ), - 'rgb_color': tuple( - 47, - 36, - 255, - ), + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , + , ]), 'supported_features': , - 'xy_color': tuple( - 0.148, - 0.055, - ), + 'xy_color': None, }), 'context': , 'entity_id': 'light.garage_light', diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 33d0e36715e..0d4706a5563 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -8,6 +8,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -55,3 +60,66 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["dj_smart_light_bulb"], +) +async def test_turn_on_white( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn_on service.""" + entity_id = "light.garage_light" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "entity_id": entity_id, + "white": 150, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["dj_smart_light_bulb"], +) +async def test_turn_off( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn_off service.""" + entity_id = "light.garage_light" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_led", "value": False}] + ) From dd399ef59f40316ab5d5841bcb754183a7967370 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 22 Jul 2025 14:35:57 +0200 Subject: [PATCH 024/235] Refactor EntityPlatform (#147927) --- .../components/generic/config_flow.py | 20 +- homeassistant/components/number/__init__.py | 4 +- homeassistant/components/sensor/__init__.py | 4 +- .../components/time_date/config_flow.py | 21 +- homeassistant/helpers/entity.py | 50 ++-- homeassistant/helpers/entity_platform.py | 216 +++++++++++++----- tests/components/go2rtc/test_init.py | 2 +- tests/helpers/test_entity.py | 4 +- tests/helpers/test_entity_platform.py | 53 +++++ 9 files changed, 256 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index b20793fe060..0621ca369db 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping import contextlib -from datetime import datetime, timedelta +from datetime import datetime from errno import EHOSTUNREACH, EIO import io import logging @@ -52,9 +52,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper -from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.setup import async_prepare_setup_platform from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -569,18 +568,9 @@ async def ws_start_preview( ) user_input = flow.preview_image_settings - # Create an EntityPlatform, needed for name translations - platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) - entity_platform = EntityPlatform( - hass=hass, - logger=_LOGGER, - domain=CAMERA_DOMAIN, - platform_name=DOMAIN, - platform=platform, - scan_interval=timedelta(seconds=3600), - entity_namespace=None, - ) - await entity_platform.async_load_translations() + # Create PlatformData, needed for name translations + platform_data = PlatformData(hass=hass, domain=CAMERA_DOMAIN, platform_name=DOMAIN) + await platform_data.async_load_translations() ha_still_url = None ha_stream_url = None diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 054f888ba33..79ed56d2a75 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -387,7 +387,9 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if (translation_key := self._unit_of_measurement_translation_key) and ( unit_of_measurement - := self.platform.default_language_platform_translations.get(translation_key) + := self.platform_data.default_language_platform_translations.get( + translation_key + ) ): if native_unit_of_measurement is not None: raise ValueError( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9948860fd5f..88f8dbbdaa2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -523,7 +523,9 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Fourth priority: Unit translation if (translation_key := self._unit_of_measurement_translation_key) and ( unit_of_measurement - := self.platform.default_language_platform_translations.get(translation_key) + := self.platform_data.default_language_platform_translations.get( + translation_key + ) ): if native_unit_of_measurement is not None: raise ValueError( diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 9ae98992acb..364bf26d1aa 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import timedelta import logging from typing import Any @@ -12,7 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -24,7 +23,6 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) -from homeassistant.setup import async_prepare_setup_platform from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES from .sensor import TimeDateSensor @@ -99,18 +97,9 @@ async def ws_start_preview( """Generate a preview.""" validated = USER_SCHEMA(msg["user_input"]) - # Create an EntityPlatform, needed for name translations - platform = await async_prepare_setup_platform(hass, {}, SENSOR_DOMAIN, DOMAIN) - entity_platform = EntityPlatform( - hass=hass, - logger=_LOGGER, - domain=SENSOR_DOMAIN, - platform_name=DOMAIN, - platform=platform, - scan_interval=timedelta(seconds=3600), - entity_namespace=None, - ) - await entity_platform.async_load_translations() + # Create PlatformData, needed for name translations + platform_data = PlatformData(hass=hass, domain=SENSOR_DOMAIN, platform_name=DOMAIN) + await platform_data.async_load_translations() @callback def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: @@ -123,7 +112,7 @@ async def ws_start_preview( preview_entity = TimeDateSensor(validated[CONF_DISPLAY_OPTIONS]) preview_entity.hass = hass - preview_entity.platform = entity_platform + preview_entity.platform_data = platform_data connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 352a77af837..6272495bcec 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -66,7 +66,7 @@ from .typing import UNDEFINED, StateType, UndefinedType timer = time.time if TYPE_CHECKING: - from .entity_platform import EntityPlatform + from .entity_platform import EntityPlatform, PlatformData _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 @@ -449,6 +449,7 @@ class Entity( # While not purely typed, it makes typehinting more useful for us # and removes the need for constant None checks or asserts. platform: EntityPlatform = None # type: ignore[assignment] + platform_data: PlatformData = None # type: ignore[assignment] # Entity description instance for this Entity entity_description: EntityDescription @@ -593,7 +594,7 @@ class Entity( return not self._attr_name if ( name_translation_key := self._name_translation_key - ) and name_translation_key in self.platform.platform_translations: + ) and name_translation_key in self.platform_data.platform_translations: return False if hasattr(self, "entity_description"): return not self.entity_description.name @@ -616,9 +617,9 @@ class Entity( if not self.has_entity_name: return None device_class_key = self.device_class or "_" - platform = self.platform + platform_domain = self.platform_data.domain name_translation_key = ( - f"component.{platform.domain}.entity_component.{device_class_key}.name" + f"component.{platform_domain}.entity_component.{device_class_key}.name" ) return component_translations.get(name_translation_key) @@ -626,13 +627,13 @@ class Entity( def _object_id_device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" return self._device_class_name_helper( - self.platform.object_id_component_translations + self.platform_data.object_id_component_translations ) @cached_property def _device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" - return self._device_class_name_helper(self.platform.component_translations) + return self._device_class_name_helper(self.platform_data.component_translations) def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class.""" @@ -643,9 +644,9 @@ class Entity( """Return translation key for entity name.""" if self.translation_key is None: return None - platform = self.platform + platform_data = self.platform_data return ( - f"component.{platform.platform_name}.entity.{platform.domain}" + f"component.{platform_data.platform_name}.entity.{platform_data.domain}" f".{self.translation_key}.name" ) @@ -654,14 +655,14 @@ class Entity( """Return translation key for unit of measurement.""" if self.translation_key is None: return None - if self.platform is None: + if self.platform_data is None: raise ValueError( f"Entity {type(self)} cannot have a translation key for " "unit of measurement before being added to the entity platform" ) - platform = self.platform + platform_data = self.platform_data return ( - f"component.{platform.platform_name}.entity.{platform.domain}" + f"component.{platform_data.platform_name}.entity.{platform_data.domain}" f".{self.translation_key}.unit_of_measurement" ) @@ -724,13 +725,13 @@ class Entity( # value. type.__getattribute__(self.__class__, "name") is type.__getattribute__(Entity, "name") - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - and self.platform + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + and self.platform_data ): name = self._name_internal( self._object_id_device_class_name, - self.platform.object_id_platform_translations, + self.platform_data.object_id_platform_translations, ) else: name = self.name @@ -739,13 +740,13 @@ class Entity( @cached_property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if not self.platform: + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + if not self.platform_data: return self._name_internal(None, {}) return self._name_internal( self._device_class_name, - self.platform.platform_translations, + self.platform_data.platform_translations, ) @cached_property @@ -986,7 +987,7 @@ class Entity( raise RuntimeError(f"Attribute hass is None for {self}") # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 + # EntityComponent and can be removed in HA Core 2026.8 if self.platform is None and not self._no_platform_reported: # type: ignore[unreachable] report_issue = self._suggest_report_issue() # type: ignore[unreachable] _LOGGER.warning( @@ -1351,6 +1352,7 @@ class Entity( self.hass = hass self.platform = platform + self.platform_data = platform.platform_data self.parallel_updates = parallel_updates self._platform_state = EntityPlatformState.ADDING @@ -1494,7 +1496,7 @@ class Entity( Not to be extended by integrations. """ # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 + # EntityComponent and can be removed in HA Core 2026.8 if self.platform: del entity_sources(self.hass)[self.entity_id] @@ -1626,9 +1628,9 @@ class Entity( def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - platform_name = self.platform.platform_name if self.platform else None + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + platform_name = self.platform_data.platform_name if self.platform_data else None return async_suggest_report_issue( self.hass, integration_domain=platform_name, module=type(self).__module__ ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index e798e85ed02..bf089dae765 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -44,6 +44,7 @@ from . import ( service, translation, ) +from .deprecation import deprecated_function from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later from .issue_registry import IssueSeverity, async_create_issue @@ -126,6 +127,77 @@ class EntityPlatformModule(Protocol): """Set up an integration platform from a config entry.""" +class PlatformData: + """Information about a platform, used by entities.""" + + def __init__( + self, + hass: HomeAssistant, + *, + domain: str, + platform_name: str, + ) -> None: + """Initialize the base entity platform.""" + self.hass = hass + self.domain = domain + self.platform_name = platform_name + self.component_translations: dict[str, str] = {} + self.platform_translations: dict[str, str] = {} + self.object_id_component_translations: dict[str, str] = {} + self.object_id_platform_translations: dict[str, str] = {} + self.default_language_platform_translations: dict[str, str] = {} + + async def _async_get_translations( + self, language: str, category: str, integration: str + ) -> dict[str, str]: + """Get translations for a language, category, and integration.""" + try: + return await translation.async_get_translations( + self.hass, language, category, {integration} + ) + except Exception as err: # noqa: BLE001 + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + + async def async_load_translations(self) -> None: + """Load translations.""" + hass = self.hass + object_id_language = ( + hass.config.language + if hass.config.language in languages.NATIVE_ENTITY_IDS + else languages.DEFAULT_LANGUAGE + ) + config_language = hass.config.language + self.component_translations = await self._async_get_translations( + config_language, "entity_component", self.domain + ) + self.platform_translations = await self._async_get_translations( + config_language, "entity", self.platform_name + ) + if object_id_language == config_language: + self.object_id_component_translations = self.component_translations + self.object_id_platform_translations = self.platform_translations + else: + self.object_id_component_translations = await self._async_get_translations( + object_id_language, "entity_component", self.domain + ) + self.object_id_platform_translations = await self._async_get_translations( + object_id_language, "entity", self.platform_name + ) + if config_language == languages.DEFAULT_LANGUAGE: + self.default_language_platform_translations = self.platform_translations + else: + self.default_language_platform_translations = ( + await self._async_get_translations( + languages.DEFAULT_LANGUAGE, "entity", self.platform_name + ) + ) + + class EntityPlatform: """Manage the entities for a single platform. @@ -147,8 +219,6 @@ class EntityPlatform: """Initialize the entity platform.""" self.hass = hass self.logger = logger - self.domain = domain - self.platform_name = platform_name self.platform = platform self.scan_interval = scan_interval self.scan_interval_seconds = scan_interval.total_seconds() @@ -157,11 +227,6 @@ class EntityPlatform: # Storage for entities for this specific platform only # which are indexed by entity_id self.entities: dict[str, Entity] = {} - self.component_translations: dict[str, str] = {} - self.platform_translations: dict[str, str] = {} - self.object_id_component_translations: dict[str, str] = {} - self.object_id_platform_translations: dict[str, str] = {} - self.default_language_platform_translations: dict[str, str] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -195,6 +260,10 @@ class EntityPlatform: DATA_DOMAIN_PLATFORM_ENTITIES, {} ).setdefault(key, {}) + self.platform_data = PlatformData( + hass, domain=domain, platform_name=platform_name + ) + def __repr__(self) -> str: """Represent an EntityPlatform.""" return ( @@ -362,7 +431,7 @@ class EntityPlatform: hass = self.hass full_name = f"{self.platform_name}.{self.domain}" - await self.async_load_translations() + await self.platform_data.async_load_translations() logger.info("Setting up %s", full_name) warn_task = hass.loop.call_at( @@ -457,56 +526,6 @@ class EntityPlatform: finally: warn_task.cancel() - async def _async_get_translations( - self, language: str, category: str, integration: str - ) -> dict[str, str]: - """Get translations for a language, category, and integration.""" - try: - return await translation.async_get_translations( - self.hass, language, category, {integration} - ) - except Exception as err: # noqa: BLE001 - _LOGGER.debug( - "Could not load translations for %s", - integration, - exc_info=err, - ) - return {} - - async def async_load_translations(self) -> None: - """Load translations.""" - hass = self.hass - object_id_language = ( - hass.config.language - if hass.config.language in languages.NATIVE_ENTITY_IDS - else languages.DEFAULT_LANGUAGE - ) - config_language = hass.config.language - self.component_translations = await self._async_get_translations( - config_language, "entity_component", self.domain - ) - self.platform_translations = await self._async_get_translations( - config_language, "entity", self.platform_name - ) - if object_id_language == config_language: - self.object_id_component_translations = self.component_translations - self.object_id_platform_translations = self.platform_translations - else: - self.object_id_component_translations = await self._async_get_translations( - object_id_language, "entity_component", self.domain - ) - self.object_id_platform_translations = await self._async_get_translations( - object_id_language, "entity", self.platform_name - ) - if config_language == languages.DEFAULT_LANGUAGE: - self.default_language_platform_translations = self.platform_translations - else: - self.default_language_platform_translations = ( - await self._async_get_translations( - languages.DEFAULT_LANGUAGE, "entity", self.platform_name - ) - ) - def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -1120,6 +1139,87 @@ class EntityPlatform: ]: await asyncio.gather(*tasks) + @property + def domain(self) -> str: + """Return the domain (e.g. light).""" + return self.platform_data.domain + + @property + def platform_name(self) -> str: + """Return the platform name (e.g hue).""" + return self.platform_data.platform_name + + @property + @deprecated_function( + "platform_data.component_translations", + breaks_in_ha_version="2026.8", + ) + def component_translations(self) -> dict[str, str]: + """Return the component translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.component_translations + + @property + @deprecated_function( + "platform_data.platform_translations", + breaks_in_ha_version="2026.8", + ) + def platform_translations(self) -> dict[str, str]: + """Return the platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.platform_translations + + @property + @deprecated_function( + "platform_data.object_id_component_translations", + breaks_in_ha_version="2026.8", + ) + def object_id_component_translations(self) -> dict[str, str]: + """Return the object ID component translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.object_id_component_translations + + @property + @deprecated_function( + "platform_data.object_id_platform_translations", + breaks_in_ha_version="2026.8", + ) + def object_id_platform_translations(self) -> dict[str, str]: + """Return the object ID platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.object_id_platform_translations + + @property + @deprecated_function( + "platform_data.default_language_platform_translations", + breaks_in_ha_version="2026.8", + ) + def default_language_platform_translations(self) -> dict[str, str]: + """Return the default language platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.default_language_platform_translations + + @deprecated_function( + "platform_data.async_load_translations", + breaks_in_ha_version="2026.8", + ) + async def async_load_translations(self) -> None: + """Load translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return await self.platform_data.async_load_translations() + @callback def async_calculate_suggested_object_id( diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 0a071f45ef7..e77e61346b6 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -685,7 +685,7 @@ async def test_generic_workaround( rest_client.get_jpeg_snapshot.return_value = image_bytes camera.set_stream_source("https://my_stream_url.m3u8") - with patch.object(camera.platform, "platform_name", "generic"): + with patch.object(camera.platform.platform_data, "platform_name", "generic"): image = await async_get_image(hass, camera.entity_id) assert image.content == image_bytes diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 30b25e9725d..3064d8d4260 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -781,7 +781,7 @@ async def test_warn_slow_write_state( mock_entity = entity.Entity() mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" - mock_entity.platform = MagicMock(platform_name="hue") + mock_entity.platform_data = MagicMock(platform_name="hue") mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): @@ -809,7 +809,7 @@ async def test_warn_slow_write_state_custom_component( mock_entity = CustomComponentEntity() mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" - mock_entity.platform = MagicMock(platform_name="hue") + mock_entity.platform_data = MagicMock(platform_name="hue") mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 08510364eba..53331b676fe 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2447,3 +2447,56 @@ async def test_add_entity_unknown_subentry( "Can't add entities to unknown subentry unknown-subentry " "of config entry super-mock-id" ) in caplog.text + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.parametrize( + "deprecated_attribute", + [ + "component_translations", + "platform_translations", + "object_id_component_translations", + "object_id_platform_translations", + "default_language_platform_translations", + ], +) +async def test_deprecated_attributes( + hass: HomeAssistant, + deprecated_attribute: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting the device name based on input info.""" + + platform = MockPlatform() + entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) + + assert getattr(entity_platform, deprecated_attribute) is getattr( + entity_platform.platform_data, deprecated_attribute + ) + assert ( + f"The deprecated function {deprecated_attribute} was called from " + "my_integration. It will be removed in HA Core 2026.8. Use platform_data." + f"{deprecated_attribute} instead, please report it to the author of the " + "'my_integration' custom integration" in caplog.text + ) + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deprecated_async_load_translations( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting the device name based on input info.""" + + platform = MockPlatform() + entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) + + await entity_platform.async_load_translations() + assert ( + "The deprecated function async_load_translations was called from " + "my_integration. It will be removed in HA Core 2026.8. Use platform_data." + "async_load_translations instead, please report it to the author of the " + "'my_integration' custom integration" in caplog.text + ) From e5f9788d24ef05cc960a4f436f2419d02f4b30e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 22 Jul 2025 14:15:56 +0100 Subject: [PATCH 025/235] Refactor cloud backup agent to use updated file handling methods (#149231) --- homeassistant/components/cloud/backup.py | 21 ++--- tests/components/cloud/test_backup.py | 101 +++++++++++------------ 2 files changed, 53 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index f4426eabeed..bca65a68abd 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -10,14 +10,8 @@ import random from typing import Any from aiohttp import ClientError, ClientResponseError -from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError -from hass_nabucasa.cloud_api import ( - FilesHandlerListEntry, - async_files_delete_file, - async_files_list, -) -from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 +from hass_nabucasa import Cloud, CloudApiError, CloudApiNonRetryableError, CloudError +from hass_nabucasa.files import FilesError, StorageType, StoredFile, calculate_b64md5 from homeassistant.components.backup import ( AgentBackup, @@ -186,8 +180,7 @@ class CloudBackupAgent(BackupAgent): """ backup = await self._async_get_backup(backup_id) try: - await async_files_delete_file( - self._cloud, + await self._cloud.files.delete( storage_type=StorageType.BACKUP, filename=backup["Key"], ) @@ -199,12 +192,10 @@ class CloudBackupAgent(BackupAgent): backups = await self._async_list_backups() return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] - async def _async_list_backups(self) -> list[FilesHandlerListEntry]: + async def _async_list_backups(self) -> list[StoredFile]: """List backups.""" try: - backups = await async_files_list( - self._cloud, storage_type=StorageType.BACKUP - ) + backups = await self._cloud.files.list(storage_type=StorageType.BACKUP) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err @@ -220,7 +211,7 @@ class CloudBackupAgent(BackupAgent): backup = await self._async_get_backup(backup_id) return AgentBackup.from_dict(backup["Metadata"]) - async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry: + async def _async_get_backup(self, backup_id: str) -> StoredFile: """Return a backup.""" backups = await self._async_list_backups() diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 72640ed0a0e..df46102d03d 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any -from unittest.mock import ANY, Mock, PropertyMock, patch +from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch from aiohttp import ClientError, ClientResponseError from hass_nabucasa import CloudError @@ -48,62 +48,56 @@ async def setup_integration( @pytest.fixture -def mock_delete_file() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_delete_file", - spec_set=True, - ) as delete_file: - yield delete_file +def mock_delete_file(cloud: MagicMock) -> Generator[AsyncMock]: + """Mock delete files.""" + cloud.files.delete = AsyncMock() + return cloud.files.delete @pytest.fixture -def mock_list_files() -> Generator[MagicMock]: +def mock_list_files(cloud: MagicMock) -> Generator[MagicMock]: """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_list", spec_set=True - ) as list_files: - list_files.return_value = [ - { - "Key": "462e16810d6841228828d9dd2f9e341e.tar", - "LastModified": "2024-11-22T10:49:01.182Z", - "Size": 34519040, - "Metadata": { - "addons": [], - "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", - "database_included": True, - "extra_metadata": {}, - "folders": [], - "homeassistant_included": True, - "homeassistant_version": "2024.12.0.dev0", - "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "storage-type": "backup", - }, + cloud.files.list.return_value = [ + { + "Key": "462e16810d6841228828d9dd2f9e341e.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", }, - { - "Key": "462e16810d6841228828d9dd2f9e341f.tar", - "LastModified": "2024-11-22T10:49:01.182Z", - "Size": 34519040, - "Metadata": { - "addons": [], - "backup_id": "23e64aed", - "date": "2024-11-22T11:48:48.727189+01:00", - "database_included": True, - "extra_metadata": {}, - "folders": [], - "homeassistant_included": True, - "homeassistant_version": "2024.12.0.dev0", - "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "storage-type": "backup", - }, + }, + { + "Key": "462e16810d6841228828d9dd2f9e341f.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aed", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", }, - ] - yield list_files + }, + ] + return cloud.files.list @pytest.fixture @@ -141,7 +135,7 @@ async def test_agents_list_backups( client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/info"}) response = await client.receive_json() - mock_list_files.assert_called_once_with(cloud, storage_type="backup") + mock_list_files.assert_called_once_with(storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} @@ -250,7 +244,7 @@ async def test_agents_get_backup( client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) response = await client.receive_json() - mock_list_files.assert_called_once_with(cloud, storage_type="backup") + mock_list_files.assert_called_once_with(storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} @@ -726,7 +720,6 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} mock_delete_file.assert_called_once_with( - cloud, filename="462e16810d6841228828d9dd2f9e341e.tar", storage_type=StorageType.BACKUP, ) From 3947569132503fd1bba6c6247852afa33600d6b5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 22 Jul 2025 15:50:38 +0200 Subject: [PATCH 026/235] Bump holidays to 0.77 (#149246) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index e39525563e9..05cdd2738b6 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.76", "babel==2.15.0"] + "requirements": ["holidays==0.77", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 86c0884ee9d..32edd5d3f6a 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.76"] + "requirements": ["holidays==0.77"] } diff --git a/requirements_all.txt b/requirements_all.txt index 47e753be1f3..69385af0e47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.76 +holidays==0.77 # homeassistant.components.frontend home-assistant-frontend==20250702.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d74eb8270b5..c23ba5f4d18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.76 +holidays==0.77 # homeassistant.components.frontend home-assistant-frontend==20250702.3 From 828a47db065ca828a34c0bf8150c1505a78500d4 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 22 Jul 2025 16:09:11 +0200 Subject: [PATCH 027/235] Add Z-Wave USB migration confirm step (#149243) --- homeassistant/components/zwave_js/config_flow.py | 15 ++++++++++++++- homeassistant/components/zwave_js/strings.json | 4 ++++ tests/components/zwave_js/test_config_flow.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 3e46fc6bac3..d98dcf3dac8 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -494,10 +494,23 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._usb_discovery = True if current_config_entries: - return await self.async_step_intent_migrate() + return await self.async_step_confirm_usb_migration() return await self.async_step_installation_type() + async def async_step_confirm_usb_migration( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm USB migration.""" + if user_input is not None: + return await self.async_step_intent_migrate() + return self.async_show_form( + step_id="confirm_usb_migration", + description_placeholders={ + "usb_title": self.context["title_placeholders"][CONF_NAME], + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 7f59e640ef8..4d68aa2bcbc 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -108,6 +108,10 @@ "start_addon": { "title": "Configuring add-on" }, + "confirm_usb_migration": { + "description": "You are about to migrate your Z-Wave network from the old adapter to the new adapter {usb_title}. This will take a backup of the network from the old adapter and restore the network to the new adapter.\n\nPress Submit to continue with the migration.", + "title": "Migrate to a new adapter" + }, "zeroconf_confirm": { "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", "title": "Discovered Z-Wave Server" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a1642746d03..c708b1c9d66 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -932,6 +932,11 @@ async def test_usb_discovery_migration( assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -1049,6 +1054,11 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" From 969ad232aaa92e8700a47c2375a876a9c1b637ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 22 Jul 2025 18:23:38 +0200 Subject: [PATCH 028/235] Update aioairzone-cloud to v0.6.16 (#149254) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 8694d3d06d9..41a823386e1 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.15"] + "requirements": ["aioairzone-cloud==0.6.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 69385af0e47..db43e86000c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.15 +aioairzone-cloud==0.6.16 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c23ba5f4d18..5ce409745b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.15 +aioairzone-cloud==0.6.16 # homeassistant.components.airzone aioairzone==1.0.0 From 252a46d1410a6a4bbc8aeda29f2d856d11d7d31d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 19:05:54 +0200 Subject: [PATCH 029/235] Use translation_placeholders in tuya select descriptions (#149251) --- homeassistant/components/tuya/select.py | 15 ++++++++++----- homeassistant/components/tuya/strings.json | 12 ++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 22229b3f6bf..296a5e3cc2c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -320,17 +320,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="indexed_led_type", + translation_placeholders={"index": "1"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, - translation_key="led_type_2", + translation_key="indexed_led_type", + translation_placeholders={"index": "2"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_3, entity_category=EntityCategory.CONFIG, - translation_key="led_type_3", + translation_key="indexed_led_type", + translation_placeholders={"index": "3"}, ), ), # Dimmer @@ -339,12 +342,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="indexed_led_type", + translation_placeholders={"index": "1"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, - translation_key="led_type_2", + translation_key="indexed_led_type", + translation_placeholders={"index": "2"}, ), ), } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 799d57547b2..6a7f6433d03 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -296,16 +296,8 @@ "led": "LED" } }, - "led_type_2": { - "name": "Light 2 source type", - "state": { - "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", - "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", - "led": "[%key:component::tuya::entity::select::led_type::state::led%]" - } - }, - "led_type_3": { - "name": "Light 3 source type", + "indexed_led_type": { + "name": "Light {index} source type", "state": { "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", From 316ac6253bfc0132220d39df5d8c78a972e26e4c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 19:06:14 +0200 Subject: [PATCH 030/235] Use translation_placeholders in tuya number descriptions (#149250) --- homeassistant/components/tuya/number.py | 30 ++++++++++++++-------- homeassistant/components/tuya/strings.json | 14 +++------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 415299307e3..383ece6eaee 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -266,32 +266,38 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "tgkg": ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, - translation_key="minimum_brightness", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, - translation_key="maximum_brightness", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, - translation_key="minimum_brightness_2", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, - translation_key="maximum_brightness_2", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_3, - translation_key="minimum_brightness_3", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_3, - translation_key="maximum_brightness_3", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), ), @@ -300,22 +306,26 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "tgq": ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, - translation_key="minimum_brightness", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, - translation_key="maximum_brightness", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, - translation_key="minimum_brightness_2", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, - translation_key="maximum_brightness_2", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), ), diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 6a7f6433d03..a797b9e6637 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -199,17 +199,11 @@ "maximum_brightness": { "name": "Maximum brightness" }, - "minimum_brightness_2": { - "name": "Minimum brightness 2" + "indexed_minimum_brightness": { + "name": "Minimum brightness {index}" }, - "maximum_brightness_2": { - "name": "Maximum brightness 2" - }, - "minimum_brightness_3": { - "name": "Minimum brightness 3" - }, - "maximum_brightness_3": { - "name": "Maximum brightness 3" + "indexed_maximum_brightness": { + "name": "Maximum brightness {index}" }, "move_down": { "name": "Move down" From ef3fb50018fa1f009b3a8e70910fa89401c1d0b3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 19:44:51 +0200 Subject: [PATCH 031/235] Use translation_placeholders in tuya light descriptions (#149249) --- homeassistant/components/tuya/light.py | 21 ++++++++++++++------- homeassistant/components/tuya/strings.json | 7 ++----- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 698ca302310..cb7555c38d8 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -121,7 +121,8 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { # Based on multiple reports: manufacturer customized Dimmer 2 switches TuyaLightEntityDescription( key=DPCode.SWITCH_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, ), ), @@ -149,7 +150,8 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_1, ), ), @@ -313,21 +315,24 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "tgkg": ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, brightness_max=DPCode.BRIGHTNESS_MAX_1, brightness_min=DPCode.BRIGHTNESS_MIN_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_2, brightness_max=DPCode.BRIGHTNESS_MAX_2, brightness_min=DPCode.BRIGHTNESS_MIN_2, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_3, - translation_key="light_3", + translation_key="indexed_light", + translation_placeholders={"index": "3"}, brightness=DPCode.BRIGHT_VALUE_3, brightness_max=DPCode.BRIGHTNESS_MAX_3, brightness_min=DPCode.BRIGHTNESS_MIN_3, @@ -345,12 +350,14 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_2, ), ), diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index a797b9e6637..169a2a4b81f 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -131,11 +131,8 @@ "light": { "name": "[%key:component::light::title%]" }, - "light_2": { - "name": "Light 2" - }, - "light_3": { - "name": "Light 3" + "indexed_light": { + "name": "Light {index}" }, "night_light": { "name": "Night light" From 55ac4d88556f15da267da244c8c251a8c6311247 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 22 Jul 2025 21:17:59 +0200 Subject: [PATCH 032/235] Bump aioautomower to 2.0.1 (#149262) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index d747bc00094..0234ac58e39 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.0.0"] + "requirements": ["aioautomower==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index db43e86000c..59287662d64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.0 +aioautomower==2.0.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ce409745b5..509e18fbc7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.0 +aioautomower==2.0.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 34eb99530fe919b31ec6467af73af98f2399727e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:21:59 +0200 Subject: [PATCH 033/235] Use translation_placeholders in tuya cover descriptions (#149248) Co-authored-by: Simone Chemelli --- homeassistant/components/tuya/cover.py | 18 ++++++++++++------ homeassistant/components/tuya/strings.json | 17 ++++------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 205a65431dd..7f34aa367ad 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -44,21 +44,24 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { "ckmkzq": ( TuyaCoverEntityDescription( key=DPCode.SWITCH_1, - translation_key="door", + translation_key="indexed_door", + translation_placeholders={"index": "1"}, current_state=DPCode.DOORCONTACT_STATE, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_2, - translation_key="door_2", + translation_key="indexed_door", + translation_placeholders={"index": "2"}, current_state=DPCode.DOORCONTACT_STATE_2, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_3, - translation_key="door_3", + translation_key="indexed_door", + translation_placeholders={"index": "3"}, current_state=DPCode.DOORCONTACT_STATE_3, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, @@ -78,14 +81,16 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - translation_key="curtain_2", + translation_key="indexed_curtain", + translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_STATE_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_3, - translation_key="curtain_3", + translation_key="indexed_curtain", + translation_placeholders={"index": "3"}, current_position=DPCode.PERCENT_STATE_3, set_position=DPCode.PERCENT_CONTROL_3, device_class=CoverDeviceClass.CURTAIN, @@ -122,7 +127,8 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - translation_key="curtain_2", + translation_key="indexed_curtain", + translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_CONTROL_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 169a2a4b81f..abcafc490f9 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -94,20 +94,11 @@ "curtain": { "name": "[%key:component::cover::entity_component::curtain::name%]" }, - "curtain_2": { - "name": "Curtain 2" + "indexed_curtain": { + "name": "Curtain {index}" }, - "curtain_3": { - "name": "Curtain 3" - }, - "door": { - "name": "[%key:component::cover::entity_component::door::name%]" - }, - "door_2": { - "name": "Door 2" - }, - "door_3": { - "name": "Door 3" + "indexed_door": { + "name": "Door {index}" } }, "event": { From 71c1837f39fca73cddf1ee000b9d77ff3efda53a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 22 Jul 2025 22:43:02 +0200 Subject: [PATCH 034/235] Update OpenAI title to drop "conversation" (#149263) --- homeassistant/components/openai_conversation/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 83519821f79..5a6d76a396b 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -1,6 +1,6 @@ { "domain": "openai_conversation", - "name": "OpenAI Conversation", + "name": "OpenAI", "after_dependencies": ["assist_pipeline", "intent"], "codeowners": ["@balloob"], "config_flow": true, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8782d5c84b4..431ece3f81a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4633,7 +4633,7 @@ "iot_class": "cloud_polling" }, "openai_conversation": { - "name": "OpenAI Conversation", + "name": "OpenAI", "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" From 45dbf3ef1aa75b3028047883d2cc7379c7f6c3e5 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:50:55 +0200 Subject: [PATCH 035/235] Bump uiprotect to version 7.19.0 (#149266) --- 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 5beb4ca059d..2f79154e0c5 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.18.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.19.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 59287662d64..91955a85c58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.18.1 +uiprotect==7.19.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 509e18fbc7d..b37399c8959 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.18.1 +uiprotect==7.19.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 993b0bbdd76f5519eef09679460091586115f393 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:51:03 +0200 Subject: [PATCH 036/235] Use absolute humidity device class in HomematicIP Cloud (#148905) --- homeassistant/components/homematicip_cloud/sensor.py | 10 ++++++---- tests/components/homematicip_cloud/test_sensor.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 1ed483b86ad..588e67bac95 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -46,6 +46,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, DEGREE, LIGHT_LUX, @@ -542,7 +543,9 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP absolute humidity sensor.""" - _attr_native_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER + _attr_device_class = SensorDeviceClass.ABSOLUTE_HUMIDITY + _attr_native_unit_of_measurement = CONCENTRATION_GRAMS_PER_CUBIC_METER + _attr_suggested_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hap: HomematicipHAP, device) -> None: @@ -550,7 +553,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Absolute Humidity") @property - def native_value(self) -> int | None: + def native_value(self) -> float | None: """Return the state.""" if self.functional_channel is None: return None @@ -564,8 +567,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): ): return None - # Convert from g/m³ to mg/m³ - return int(float(value) * 1000) + return round(value, 3) class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 77e90ccaff6..669cbbf664f 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -776,7 +776,7 @@ async def test_hmip_absolute_humidity_sensor( hass, mock_hap, entity_id, entity_name, device_model ) - assert ha_state.state == "6098" + assert ha_state.state == "6099.0" async def test_hmip_absolute_humidity_sensor_invalid_value( From dde73c05cbcd2d63c5b532ef54780fbabcc5f964 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 22 Jul 2025 23:06:51 +0200 Subject: [PATCH 037/235] Order selectors alphabetically in helper (#149269) --- homeassistant/helpers/selector.py | 222 +++++++++++++++--------------- 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 2429b4b23e8..ad0c909003e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -575,49 +575,6 @@ class ConstantSelector(Selector[ConstantSelectorConfig]): return self.config["value"] -class QrErrorCorrectionLevel(StrEnum): - """Possible error correction levels for QR code selector.""" - - LOW = "low" - MEDIUM = "medium" - QUARTILE = "quartile" - HIGH = "high" - - -class QrCodeSelectorConfig(BaseSelectorConfig, total=False): - """Class to represent a QR code selector config.""" - - data: str - scale: int - error_correction_level: QrErrorCorrectionLevel - - -@SELECTORS.register("qr_code") -class QrCodeSelector(Selector[QrCodeSelectorConfig]): - """QR code selector.""" - - selector_type = "qr_code" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - vol.Required("data"): str, - vol.Optional("scale"): int, - vol.Optional("error_correction_level"): vol.All( - vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value - ), - } - ) - - def __init__(self, config: QrCodeSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> Any: - """Validate the passed selection.""" - vol.Schema(vol.Any(str, None))(data) - return self.config["data"] - - class ConversationAgentSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a conversation agent selector config.""" @@ -872,6 +829,39 @@ class EntitySelector(Selector[EntitySelectorConfig]): return cast(list, vol.Schema([validate])(data)) # Output is a list +class FileSelectorConfig(BaseSelectorConfig): + """Class to represent a file selector config.""" + + accept: str # required + + +@SELECTORS.register("file") +class FileSelector(Selector[FileSelectorConfig]): + """Selector of a file.""" + + selector_type = "file" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept + vol.Required("accept"): str, + } + ) + + def __init__(self, config: FileSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + if not isinstance(data, str): + raise vol.Invalid("Value should be a string") + + UUID(data) + + return data + + class FloorSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an floor selector config.""" @@ -1213,6 +1203,49 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): return data +class QrErrorCorrectionLevel(StrEnum): + """Possible error correction levels for QR code selector.""" + + LOW = "low" + MEDIUM = "medium" + QUARTILE = "quartile" + HIGH = "high" + + +class QrCodeSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent a QR code selector config.""" + + data: str + scale: int + error_correction_level: QrErrorCorrectionLevel + + +@SELECTORS.register("qr_code") +class QrCodeSelector(Selector[QrCodeSelectorConfig]): + """QR code selector.""" + + selector_type = "qr_code" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Required("data"): str, + vol.Optional("scale"): int, + vol.Optional("error_correction_level"): vol.All( + vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value + ), + } + ) + + def __init__(self, config: QrCodeSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(vol.Any(str, None))(data) + return self.config["data"] + + select_option = vol.All( dict, vol.Schema( @@ -1295,6 +1328,41 @@ class SelectSelector(Selector[SelectSelectorConfig]): return [parent_schema(vol.Schema(str)(val)) for val in data] +class StateSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent an state selector config.""" + + entity_id: str + hide_states: list[str] + + +@SELECTORS.register("state") +class StateSelector(Selector[StateSelectorConfig]): + """Selector for an entity state.""" + + selector_type = "state" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("entity_id"): cv.entity_id, + vol.Optional("hide_states"): [str], + # The attribute to filter on, is currently deliberately not + # configurable/exposed. We are considering separating state + # selectors into two types: one for state and one for attribute. + # Limiting the public use, prevents breaking changes in the future. + # vol.Optional("attribute"): str, + } + ) + + def __init__(self, config: StateSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + state: str = vol.Schema(str)(data) + return state + + class StatisticSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a statistic selector config.""" @@ -1335,41 +1403,6 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] -class StateSelectorConfig(BaseSelectorConfig, total=False): - """Class to represent an state selector config.""" - - entity_id: str - hide_states: list[str] - - -@SELECTORS.register("state") -class StateSelector(Selector[StateSelectorConfig]): - """Selector for an entity state.""" - - selector_type = "state" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - vol.Optional("entity_id"): cv.entity_id, - vol.Optional("hide_states"): [str], - # The attribute to filter on, is currently deliberately not - # configurable/exposed. We are considering separating state - # selectors into two types: one for state and one for attribute. - # Limiting the public use, prevents breaking changes in the future. - # vol.Optional("attribute"): str, - } - ) - - def __init__(self, config: StateSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> str: - """Validate the passed selection.""" - state: str = vol.Schema(str)(data) - return state - - @SELECTORS.register("target") class TargetSelector(Selector[TargetSelectorConfig]): """Selector of a target value (area ID, device ID, entity ID etc). @@ -1559,39 +1592,6 @@ class TriggerSelector(Selector[TriggerSelectorConfig]): return vol.Schema(cv.TRIGGER_SCHEMA)(data) -class FileSelectorConfig(BaseSelectorConfig): - """Class to represent a file selector config.""" - - accept: str # required - - -@SELECTORS.register("file") -class FileSelector(Selector[FileSelectorConfig]): - """Selector of a file.""" - - selector_type = "file" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept - vol.Required("accept"): str, - } - ) - - def __init__(self, config: FileSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> str: - """Validate the passed selection.""" - if not isinstance(data, str): - raise vol.Invalid("Value should be a string") - - UUID(data) - - return data - - dumper.add_representer( Selector, lambda dumper, value: dumper.represent_odict( From 2f6c0a1b7f28480ac236c2b484d169ea4bbea8bf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:30:23 +0200 Subject: [PATCH 038/235] Bump aioimmich to 0.11.0 (#149272) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 906356a4bc9..16ae1671e3a 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "silver", - "requirements": ["aioimmich==0.10.2"] + "requirements": ["aioimmich==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91955a85c58..8544d2125e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -283,7 +283,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.2 +aioimmich==0.11.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b37399c8959..097e7cbbea1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -268,7 +268,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.2 +aioimmich==0.11.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 9fd2ad425c5f11c3369d61361a6c39a6d2fca167 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 23 Jul 2025 07:22:48 +0200 Subject: [PATCH 039/235] Refactor KNX UI conditional selectors and migrate store data (#146067) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/knx/light.py | 91 ++++---- homeassistant/components/knx/manifest.json | 2 +- .../components/knx/storage/config_store.py | 19 +- homeassistant/components/knx/storage/const.py | 21 +- .../knx/storage/entity_store_schema.py | 195 ++++++++++-------- .../components/knx/storage/knx_selector.py | 26 +++ .../components/knx/storage/migration.py | 42 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/knx/conftest.py | 19 +- .../fixtures/config_store_binarysensor.json | 2 +- .../knx/fixtures/config_store_cover.json | 2 +- .../knx/fixtures/config_store_light.json | 142 +++++++++++++ .../fixtures/config_store_light_switch.json | 3 +- .../knx/fixtures/config_store_light_v1.json | 140 +++++++++++++ tests/components/knx/test_config_store.py | 48 +++++ tests/components/knx/test_light.py | 15 +- 17 files changed, 618 insertions(+), 153 deletions(-) create mode 100644 homeassistant/components/knx/storage/migration.py create mode 100644 tests/components/knx/fixtures/config_store_light.json create mode 100644 tests/components/knx/fixtures/config_store_light_v1.json diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index cbecb878e12..1ab6883a437 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -33,6 +33,7 @@ from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule from .schema import LightSchema from .storage.const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_ENTITY, @@ -223,7 +224,7 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value: color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE - color_dpt = conf.get_dpt(CONF_GA_COLOR) + color_dpt = conf.get_dpt(CONF_COLOR, CONF_GA_COLOR) return XknxLight( xknx, @@ -232,59 +233,77 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight group_address_switch_state=conf.get_state_and_passive(CONF_GA_SWITCH), group_address_brightness=conf.get_write(CONF_GA_BRIGHTNESS), group_address_brightness_state=conf.get_state_and_passive(CONF_GA_BRIGHTNESS), - group_address_color=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGB - else None, - group_address_color_state=conf.get_state_and_passive(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGB - else None, - group_address_rgbw=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGBW - else None, - group_address_rgbw_state=conf.get_state_and_passive(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGBW - else None, - group_address_hue=conf.get_write(CONF_GA_HUE), - group_address_hue_state=conf.get_state_and_passive(CONF_GA_HUE), - group_address_saturation=conf.get_write(CONF_GA_SATURATION), - group_address_saturation_state=conf.get_state_and_passive(CONF_GA_SATURATION), - group_address_xyy_color=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.XYY - else None, - group_address_xyy_color_state=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.XYY - else None, + group_address_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_rgbw=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_rgbw_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_hue=conf.get_write(CONF_COLOR, CONF_GA_HUE), + group_address_hue_state=conf.get_state_and_passive(CONF_COLOR, CONF_GA_HUE), + group_address_saturation=conf.get_write(CONF_COLOR, CONF_GA_SATURATION), + group_address_saturation_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_SATURATION + ), + group_address_xyy_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), + group_address_xyy_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), group_address_tunable_white=group_address_tunable_white, group_address_tunable_white_state=group_address_tunable_white_state, group_address_color_temperature=group_address_color_temp, group_address_color_temperature_state=group_address_color_temp_state, - group_address_switch_red=conf.get_write(CONF_GA_RED_SWITCH), - group_address_switch_red_state=conf.get_state_and_passive(CONF_GA_RED_SWITCH), - group_address_brightness_red=conf.get_write(CONF_GA_RED_BRIGHTNESS), - group_address_brightness_red_state=conf.get_state_and_passive( - CONF_GA_RED_BRIGHTNESS + group_address_switch_red=conf.get_write(CONF_COLOR, CONF_GA_RED_SWITCH), + group_address_switch_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_SWITCH ), - group_address_switch_green=conf.get_write(CONF_GA_GREEN_SWITCH), + group_address_brightness_red=conf.get_write(CONF_COLOR, CONF_GA_RED_BRIGHTNESS), + group_address_brightness_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_BRIGHTNESS + ), + group_address_switch_green=conf.get_write(CONF_COLOR, CONF_GA_GREEN_SWITCH), group_address_switch_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_SWITCH + CONF_COLOR, CONF_GA_GREEN_SWITCH ), group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), group_address_brightness_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_BRIGHTNESS + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS ), group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), group_address_brightness_blue_state=conf.get_state_and_passive( - CONF_GA_BLUE_BRIGHTNESS + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS ), - group_address_switch_white=conf.get_write(CONF_GA_WHITE_SWITCH), + group_address_switch_white=conf.get_write(CONF_COLOR, CONF_GA_WHITE_SWITCH), group_address_switch_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_SWITCH + CONF_COLOR, CONF_GA_WHITE_SWITCH + ), + group_address_brightness_white=conf.get_write( + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), - group_address_brightness_white=conf.get_write(CONF_GA_WHITE_BRIGHTNESS), group_address_brightness_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_BRIGHTNESS + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), color_temperature_type=color_temperature_type, min_kelvin=knx_config[CONF_COLOR_TEMP_MIN], diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index baa830bfaa4..5145d2d22f8 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.4.1.91934" + "knx-frontend==2025.6.13.181749" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2899448a128..2e93256de47 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -13,10 +13,11 @@ from homeassistant.util.ulid import ulid_now from ..const import DOMAIN from .const import CONF_DATA +from .migration import migrate_1_to_2 _LOGGER = logging.getLogger(__name__) -STORAGE_VERSION: Final = 1 +STORAGE_VERSION: Final = 2 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -45,6 +46,20 @@ class PlatformControllerBase(ABC): """Update an existing entities configuration.""" +class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]): + """Storage handler for KNXConfigStore.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + # version 2 introduced in 2025.8 + migrate_1_to_2(old_data) + + return old_data + + class KNXConfigStore: """Manage KNX config store data.""" @@ -56,7 +71,7 @@ class KNXConfigStore: """Initialize config store.""" self.hass = hass self.config_entry = config_entry - self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY) self.data = KNXConfigStoreModel(entities={}) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 7cae0e9bbf6..78cd38c9d00 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -2,6 +2,7 @@ from typing import Final +# Common CONF_DATA: Final = "data" CONF_ENTITY: Final = "entity" CONF_DEVICE_INFO: Final = "device_info" @@ -12,10 +13,22 @@ CONF_DPT: Final = "dpt" CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" -CONF_GA_COLOR_TEMP: Final = "ga_color_temp" + +# Cover +CONF_GA_UP_DOWN: Final = "ga_up_down" +CONF_GA_STOP: Final = "ga_stop" +CONF_GA_STEP: Final = "ga_step" +CONF_GA_POSITION_SET: Final = "ga_position_set" +CONF_GA_POSITION_STATE: Final = "ga_position_state" +CONF_GA_ANGLE: Final = "ga_angle" + +# Light CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MAX: Final = "color_temp_max" CONF_GA_BRIGHTNESS: Final = "ga_brightness" +CONF_GA_COLOR_TEMP: Final = "ga_color_temp" +# Light/color +CONF_COLOR: Final = "color" CONF_GA_COLOR: Final = "ga_color" CONF_GA_RED_BRIGHTNESS: Final = "ga_red_brightness" CONF_GA_RED_SWITCH: Final = "ga_red_switch" @@ -27,9 +40,3 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness" CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" -CONF_GA_UP_DOWN: Final = "ga_up_down" -CONF_GA_STOP: Final = "ga_stop" -CONF_GA_STEP: Final = "ga_step" -CONF_GA_POSITION_SET: Final = "ga_position_set" -CONF_GA_POSITION_STATE: Final = "ga_position_state" -CONF_GA_ANGLE: Final = "ga_angle" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 85bcbd1809f..6c41a7d29e7 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -29,6 +29,7 @@ from ..const import ( ) from ..validation import sync_state_validator from .const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_DATA, @@ -43,23 +44,20 @@ from .const import ( CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_SWITCH, CONF_GA_HUE, - CONF_GA_PASSIVE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SENSOR, - CONF_GA_STATE, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_SWITCH, CONF_GA_UP_DOWN, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, - CONF_GA_WRITE, ) -from .knx_selector import GASelector +from .knx_selector import GASelector, GroupSelect BASE_ENTITY_SCHEMA = vol.All( { @@ -87,24 +85,6 @@ BASE_ENTITY_SCHEMA = vol.All( ) -def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: - """Validate group address schema or remove key if no address is set.""" - # frontend will return {key: {"write": None, "state": None}} for unused GA sets - # -> remove this entirely for optional keys - # if one GA is set, validate as usual - return { - vol.Optional(key): ga_selector, - vol.Remove(key): vol.Schema( - { - vol.Optional(CONF_GA_WRITE): None, - vol.Optional(CONF_GA_STATE): None, - vol.Optional(CONF_GA_PASSIVE): vol.IsFalse(), # None or empty list - }, - extra=vol.ALLOW_EXTRA, - ), - } - - BINARY_SENSOR_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -134,16 +114,14 @@ COVER_SCHEMA = vol.Schema( vol.Required(DOMAIN): vol.All( vol.Schema( { - **optional_ga_schema(CONF_GA_UP_DOWN, GASelector(state=False)), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_STOP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_STEP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_POSITION_SET, GASelector(state=False)), - **optional_ga_schema( - CONF_GA_POSITION_STATE, GASelector(write=False) - ), + vol.Optional(CONF_GA_STOP): GASelector(state=False), + vol.Optional(CONF_GA_STEP): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_ANGLE, GASelector()), + vol.Optional(CONF_GA_ANGLE): GASelector(), vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), vol.Optional( CoverConf.TRAVELLING_TIME_DOWN, default=25 @@ -208,72 +186,111 @@ class LightColorModeSchema(StrEnum): HSV = "hsv" -_LIGHT_COLOR_MODE_SCHEMA = "_light_color_mode_schema" +_hs_color_inclusion_msg = ( + "'Hue', 'Saturation' and 'Brightness' addresses are required for HSV configuration" +) -_COMMON_LIGHT_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - **optional_ga_schema( - CONF_GA_COLOR_TEMP, GASelector(write_required=True, dpt=ColorTempModes) + +LIGHT_KNX_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_GA_BRIGHTNESS): GASelector(write_required=True), + vol.Optional(CONF_GA_COLOR_TEMP): GASelector( + write_required=True, dpt=ColorTempModes + ), + vol.Optional(CONF_COLOR): GroupSelect( + vol.Schema( + { + vol.Optional(CONF_GA_COLOR): GASelector( + write_required=True, dpt=LightColorMode + ) + } + ), + vol.Schema( + { + vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_RED_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( + write_required=False + ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( + write_required=False + ), + } + ), + vol.Schema( + { + vol.Required(CONF_GA_HUE): GASelector(write_required=True), + vol.Required(CONF_GA_SATURATION): GASelector( + write_required=True + ), + } + ), + # msg="error in `color` config", + ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } + ), + vol.Any( + vol.Schema( + {vol.Required(CONF_GA_SWITCH): object}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Schema( # brightness addresses are required in INDIVIDUAL_COLOR_SCHEMA + {vol.Required(CONF_COLOR): {vol.Required(CONF_GA_RED_BRIGHTNESS): object}}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( - vol.Coerce(int), vol.Range(min=1) + msg="either 'address' or 'individual_colors' is required", + ), + vol.Any( + vol.Schema( # 'brightness' is non-optional for hs-color + { + vol.Required(CONF_GA_BRIGHTNESS, msg=_hs_color_inclusion_msg): object, + vol.Required(CONF_COLOR): { + vol.Required(CONF_GA_HUE, msg=_hs_color_inclusion_msg): object, + vol.Required( + CONF_GA_SATURATION, msg=_hs_color_inclusion_msg + ): object, + }, + }, + extra=vol.ALLOW_EXTRA, ), - }, - extra=vol.REMOVE_EXTRA, -) - -_DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.DEFAULT.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema( - CONF_GA_COLOR, - GASelector(write_required=True, dpt=LightColorMode), + vol.Schema( # hs-colors not used + { + vol.Optional(CONF_COLOR): { + vol.Optional(CONF_GA_HUE): None, + vol.Optional(CONF_GA_SATURATION): None, + }, + }, + extra=vol.ALLOW_EXTRA, ), - } + msg=_hs_color_inclusion_msg, + ), ) -_INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.INDIVIDUAL.value, - **optional_ga_schema(CONF_GA_SWITCH, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_RED_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_GREEN_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BLUE_SWITCH, GASelector(write_required=False)), - **optional_ga_schema(CONF_GA_WHITE_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_WHITE_SWITCH, GASelector(write_required=False)), - } -) - -_HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.HSV.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Required(CONF_GA_BRIGHTNESS): GASelector(write_required=True), - vol.Required(CONF_GA_HUE): GASelector(write_required=True), - vol.Required(CONF_GA_SATURATION): GASelector(write_required=True), - } -) - - -LIGHT_KNX_SCHEMA = cv.key_value_schemas( - _LIGHT_COLOR_MODE_SCHEMA, - default_schema=_DEFAULT_LIGHT_SCHEMA, - value_schemas={ - LightColorModeSchema.DEFAULT: _DEFAULT_LIGHT_SCHEMA, - LightColorModeSchema.INDIVIDUAL: _INDIVIDUAL_LIGHT_SCHEMA, - LightColorModeSchema.HSV: _HSV_LIGHT_SCHEMA, - }, -) LIGHT_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index a1510dbb384..fe909f1fd0a 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -1,5 +1,6 @@ """Selectors for KNX.""" +from collections.abc import Hashable, Iterable from enum import Enum from typing import Any @@ -9,6 +10,31 @@ from ..validation import ga_validator, maybe_ga_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE +class GroupSelect(vol.Any): + """Use the first validated value. + + This is a version of vol.Any with custom error handling to + show proper invalid markers for sub-schema items in the UI. + """ + + def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> Any: + """Execute the validation functions.""" + errors: list[vol.Invalid] = [] + for func in funcs: + try: + if path is None: + return func(v) + return func(path, v) + except vol.Invalid as e: + errors.append(e) + if errors: + raise next( + (err for err in errors if "extra keys not allowed" not in err.msg), + errors[0], + ) + raise vol.AnyInvalid(self.msg or "no valid value found", path=path) + + class GASelector: """Selector for a KNX group address structure.""" diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py new file mode 100644 index 00000000000..f7d7941e5cc --- /dev/null +++ b/homeassistant/components/knx/storage/migration.py @@ -0,0 +1,42 @@ +"""Migration functions for KNX config store schema.""" + +from typing import Any + +from homeassistant.const import Platform + +from . import const as store_const + + +def migrate_1_to_2(data: dict[str, Any]) -> None: + """Migrate from schema 1 to schema 2.""" + if lights := data.get("entities", {}).get(Platform.LIGHT): + for light in lights.values(): + _migrate_light_schema_1_to_2(light["knx"]) + + +def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None: + """Migrate light color mode schema.""" + # Remove no more needed helper data from schema + light_knx_data.pop("_light_color_mode_schema", None) + + # Move color related group addresses to new "color" key + color: dict[str, Any] = {} + for color_key in ( + # optional / required and exclusive keys are the same in old and new schema + store_const.CONF_GA_COLOR, + store_const.CONF_GA_HUE, + store_const.CONF_GA_SATURATION, + store_const.CONF_GA_RED_BRIGHTNESS, + store_const.CONF_GA_RED_SWITCH, + store_const.CONF_GA_GREEN_BRIGHTNESS, + store_const.CONF_GA_GREEN_SWITCH, + store_const.CONF_GA_BLUE_BRIGHTNESS, + store_const.CONF_GA_BLUE_SWITCH, + store_const.CONF_GA_WHITE_BRIGHTNESS, + store_const.CONF_GA_WHITE_SWITCH, + ): + if color_key in light_knx_data: + color[color_key] = light_knx_data.pop(color_key) + + if color: + light_knx_data[store_const.CONF_COLOR] = color diff --git a/requirements_all.txt b/requirements_all.txt index 8544d2125e2..c6b57127837 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1304,7 +1304,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.6.13.181749 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 097e7cbbea1..20500240a5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1126,7 +1126,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.6.13.181749 # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 32f7745a6e0..576fce802c0 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -76,6 +76,7 @@ class KNXTestKit: yaml_config: ConfigType | None = None, config_store_fixture: str | None = None, add_entry_to_hass: bool = True, + state_updater: bool = True, ) -> None: """Create the KNX integration.""" @@ -118,14 +119,24 @@ class KNXTestKit: self.mock_config_entry.add_to_hass(self.hass) knx_config = {DOMAIN: yaml_config or {}} - with patch( - "xknx.xknx.knx_interface_factory", - return_value=knx_ip_interface_mock(), - side_effect=fish_xknx, + with ( + patch( + "xknx.xknx.knx_interface_factory", + return_value=knx_ip_interface_mock(), + side_effect=fish_xknx, + ), ): + state_updater_patcher = patch( + "xknx.xknx.StateUpdater.register_remote_value" + ) + if not state_updater: + state_updater_patcher.start() + await async_setup_component(self.hass, DOMAIN, knx_config) await self.hass.async_block_till_done() + state_updater_patcher.stop() + ######################## # Telegram counter tests ######################## diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 427867cff8c..2b6e5887f9e 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_cover.json b/tests/components/knx/fixtures/config_store_cover.json index 6ec8dcc90fa..8f89a4ee47b 100644 --- a/tests/components/knx/fixtures/config_store_cover.json +++ b/tests/components/knx/fixtures/config_store_cover.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json new file mode 100644 index 00000000000..61ec1044746 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light.json @@ -0,0 +1,142 @@ +{ + "version": 2, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + } + } + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store_light_switch.json b/tests/components/knx/fixtures/config_store_light_switch.json index 5eabcfa87f9..0b14535bbea 100644 --- a/tests/components/knx/fixtures/config_store_light_switch.json +++ b/tests/components/knx/fixtures/config_store_light_switch.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { @@ -33,7 +33,6 @@ "knx": { "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/21", "state": "1/0/21", diff --git a/tests/components/knx/fixtures/config_store_light_v1.json b/tests/components/knx/fixtures/config_store_light_v1.json new file mode 100644 index 00000000000..3e049e145f2 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light_v1.json @@ -0,0 +1,140 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "individual", + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "hsv", + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index aee0a4036ff..3e902f8f402 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -14,6 +14,7 @@ from homeassistant.helpers import entity_registry as er from . import KnxEntityGenerator from .conftest import KNXTestKit +from tests.common import async_load_json_object_fixture from tests.typing import WebSocketGenerator @@ -379,6 +380,7 @@ async def test_validate_entity( await knx.setup_integration() client = await hass_ws_client(hass) + # valid data await client.send_json_auto_id( { "type": "knx/validate_entity", @@ -410,3 +412,49 @@ async def test_validate_entity( assert res["result"]["errors"][0]["path"] == ["data", "knx", "ga_switch", "write"] assert res["result"]["errors"][0]["error_message"] == "required key not provided" assert res["result"]["error_base"].startswith("required key not provided") + + # invalid group_select data + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.LIGHT, + "data": { + "entity": {"name": "test_name"}, + "knx": { + "color": { + "ga_red_brightness": {"write": "1/2/3"}, + "ga_green_brightness": {"write": "1/2/4"}, + # ga_blue_brightness is missing - which is required + } + }, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + # This shall test that a required key of the second GroupSelect schema is missing + # and not yield the "extra keys not allowed" error of the first GroupSelect Schema + assert res["result"]["errors"][0]["path"] == [ + "data", + "knx", + "color", + "ga_blue_brightness", + ] + assert res["result"]["errors"][0]["error_message"] == "required key not provided" + assert res["result"]["error_base"].startswith("required key not provided") + + +async def test_migration_1_to_2( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +) -> None: + """Test migration from schema 1 to schema 2.""" + await knx.setup_integration( + config_store_fixture="config_store_light_v1.json", state_updater=False + ) + new_data = await async_load_json_object_fixture( + hass, "config_store_light.json", "knx" + ) + assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index fb0246763a4..5edf150ef4f 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1182,7 +1182,6 @@ async def test_light_ui_create( entity_data={"name": "test"}, knx_data={ "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1223,7 +1222,6 @@ async def test_light_ui_color_temp( "write": "3/3/3", "dpt": color_temp_mode, }, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1257,7 +1255,6 @@ async def test_light_ui_multi_mode( knx_data={ "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/1", "passive": [], @@ -1275,11 +1272,13 @@ async def test_light_ui_multi_mode( "state": "0/6/3", "passive": [], }, - "ga_color": { - "write": "0/6/4", - "dpt": "251.600", - "state": "0/6/5", - "passive": [], + "color": { + "ga_color": { + "write": "0/6/4", + "dpt": "251.600", + "state": "0/6/5", + "passive": [], + }, }, }, ) From 5f2f0386093f3513300d85f097089207ec8edbca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jul 2025 20:14:41 -1000 Subject: [PATCH 040/235] Bump dbus-fast to 2.44.2 (#149281) --- 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 cf3ee8e0db9..3b1e6e70ff6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", - "dbus-fast==2.43.0", + "dbus-fast==2.44.2", "habluetooth==4.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa0e1768d52..9f0e0408efd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==45.0.3 -dbus-fast==2.43.0 +dbus-fast==2.44.2 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index c6b57127837..479e5598aaf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -762,7 +762,7 @@ datadog==0.15.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.2 # homeassistant.components.debugpy debugpy==1.8.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20500240a5e..eae65039b8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -665,7 +665,7 @@ datadog==0.15.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.2 # homeassistant.components.debugpy debugpy==1.8.14 From 40571dff3d4f41b440b230b45039fd6a66927cb4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 09:33:27 +0200 Subject: [PATCH 041/235] Replace typo "effect" with "affect" in `insteon` (#149292) --- homeassistant/components/insteon/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 3a15d667ca7..dedbc9c4fa9 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -18,16 +18,16 @@ } }, "hubv1": { - "title": "Insteon Hub Version 1", - "description": "Configure the Insteon Hub Version 1 (pre-2014).", + "title": "Insteon Hub version 1", + "description": "Configure the Insteon Hub version 1 (pre-2014).", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" } }, "hubv2": { - "title": "Insteon Hub Version 2", - "description": "Configure the Insteon Hub Version 2.", + "title": "Insteon Hub version 2", + "description": "Configure the Insteon Hub version 2.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", @@ -144,7 +144,7 @@ }, "reload": { "name": "[%key:common::action::reload%]", - "description": "If enabled, all current records are cleared from memory (does not effect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." + "description": "If enabled, all current records are cleared from memory (does not affect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." } } }, From a5ab52301494aa152674cf974a5831c3296f78c1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 10:03:04 +0200 Subject: [PATCH 042/235] Fix sentence-casing in `tomorrowio` (#149293) --- homeassistant/components/tomorrowio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index c3f52155d29..033b338f1a4 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -23,10 +23,10 @@ "options": { "step": { "init": { - "title": "Update Tomorrow.io Options", + "title": "Update Tomorrow.io options", "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", "data": { - "timestep": "Min. Between NowCast Forecasts" + "timestep": "Minutes between NowCast forecasts" } } } From 9a6ba225e4887d7f7871d5a49334f05452e65fbf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 10:33:31 +0200 Subject: [PATCH 043/235] Fix typo "paela" in `miele` (#149295) --- homeassistant/components/miele/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 865f3313ad5..18893c238fe 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -698,7 +698,7 @@ "parsnip_cut_into_batons": "Parsnip (cut into batons)", "parsnip_diced": "Parsnip (diced)", "parsnip_sliced": "Parsnip (sliced)", - "pasta_paela": "Pasta/Paela", + "pasta_paela": "Pasta/paella", "pears_halved": "Pears (halved)", "pears_quartered": "Pears (quartered)", "pears_to_cook_large_halved": "Pears to cook (large, halved)", From 51a46a128c4544a2fc78904e617615a7e67cddda Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:46:52 +0200 Subject: [PATCH 044/235] Begin migrating unifiprotect to use the public API (#149126) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/__init__.py | 43 ++++- .../components/unifiprotect/config_flow.py | 20 +++ .../components/unifiprotect/const.py | 2 +- .../components/unifiprotect/strings.json | 30 +++- .../components/unifiprotect/utils.py | 3 + tests/components/unifiprotect/conftest.py | 2 + .../unifiprotect/fixtures/sample_nvr.json | 2 +- .../unifiprotect/test_config_flow.py | 144 +++++++++++++-- tests/components/unifiprotect/test_init.py | 165 ++++++++++++++++++ .../unifiprotect/test_media_source.py | 1 + 10 files changed, 380 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 440250d45a3..5fa9a85d341 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -16,9 +16,13 @@ from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.test_util.anonymize import anonymize_data # noqa: F401 from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -33,7 +37,6 @@ from .const import ( DEVICES_THAT_ADOPT, DOMAIN, MIN_REQUIRED_PROTECT_V, - OUTDATED_LOG_MESSAGE, PLATFORMS, ) from .data import ProtectData, UFPConfigEntry @@ -69,6 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" + protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") @@ -89,6 +93,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: bootstrap = protect.bootstrap nvr_info = bootstrap.nvr auth_user = bootstrap.users.get(bootstrap.auth_user_id) + + # Check if API key is missing + if not protect.is_api_key_set() and auth_user and nvr_info.can_write(auth_user): + try: + new_api_key = await protect.create_api_key( + name=f"Home Assistant ({hass.config.location_name})" + ) + except NotAuthorized as err: + _LOGGER.error("Failed to create API key: %s", err) + else: + protect.set_api_key(new_api_key) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_API_KEY: new_api_key} + ) + + if not protect.is_api_key_set(): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_key_required", + ) + if auth_user and auth_user.cloud_account: ir.async_create_issue( hass, @@ -103,12 +128,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: ) if nvr_info.version < MIN_REQUIRED_PROTECT_V: - _LOGGER.error( - OUTDATED_LOG_MESSAGE, - nvr_info.version, - MIN_REQUIRED_PROTECT_V, + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="protect_version", + translation_placeholders={ + "current_version": str(nvr_info.version), + "min_version": str(MIN_REQUIRED_PROTECT_V), + }, ) - return False if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index c83b3f11010..0eab326d609 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -23,6 +23,7 @@ from homeassistant.config_entries import ( OptionsFlowWithReload, ) from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_ID, CONF_PASSWORD, @@ -214,6 +215,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -247,6 +249,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): session = async_create_clientsession( self.hass, cookie_jar=CookieJar(unsafe=True) ) + public_api_session = async_get_clientsession(self.hass) host = user_input[CONF_HOST] port = user_input.get(CONF_PORT, DEFAULT_PORT) @@ -254,10 +257,12 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): protect = ProtectApiClient( session=session, + public_api_session=public_api_session, host=host, port=port, username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], + api_key=user_input[CONF_API_KEY], verify_ssl=verify_ssl, cache_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), config_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), @@ -286,6 +291,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): auth_user = bootstrap.users.get(bootstrap.auth_user_id) if auth_user and auth_user.cloud_account: errors["base"] = "cloud_user" + try: + await protect.get_meta_info() + except NotAuthorized as ex: + _LOGGER.debug(ex) + errors[CONF_API_KEY] = "invalid_auth" + except ClientError as ex: + _LOGGER.error(ex) + errors["base"] = "cannot_connect" return nvr_data, errors @@ -318,12 +331,18 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): } return self.async_show_form( step_id="reauth_confirm", + description_placeholders={ + "local_user_documentation_url": await async_local_user_documentation_url( + self.hass + ), + }, data_schema=vol.Schema( { vol.Required( CONF_USERNAME, default=form_data.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -366,6 +385,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index d041b713125..f7138c24341 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -52,7 +52,7 @@ DEVICES_THAT_ADOPT = { DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} -MIN_REQUIRED_PROTECT_V = Version("1.20.0") +MIN_REQUIRED_PROTECT_V = Version("6.0.0") OUTDATED_LOG_MESSAGE = ( "You are running v%s of UniFi Protect. Minimum required version is v%s. Please" " upgrade UniFi Protect and then retry" diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 23c662f5d71..f20b56d29e4 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -10,19 +10,27 @@ "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "host": "Hostname or IP address of your UniFi Protect device." + "host": "Hostname or IP address of your UniFi Protect device.", + "api_key": "API key for your local user account." } }, "reauth_confirm": { "title": "UniFi Protect reauth", + "description": "Your credentials or API key seem to be missing or invalid. For instructions on how to create a local user or generate a new API key, please refer to the documentation: {local_user_documentation_url}", "data": { "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account.", + "username": "Username for your local (not cloud) user account." } }, "discovery_confirm": { @@ -30,14 +38,18 @@ "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account." } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", + "protect_version": "Minimum required version is v6.0.0. Please upgrade UniFi Protect and then retry.", "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { @@ -669,5 +681,13 @@ } } } + }, + "exceptions": { + "api_key_required": { + "message": "API key is required. Please reauthenticate this integration to provide an API key." + }, + "protect_version": { + "message": "Your UniFi Protect version ({current_version}) is too old. Minimum required: {min_version}." + } } } diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 61314346d32..9071a24eae6 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -110,13 +110,16 @@ def async_create_api_client( """Create ProtectApiClient from config entry.""" session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + public_api_session = async_create_clientsession(hass) return ProtectApiClient( host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], + api_key=entry.data.get("api_key"), verify_ssl=entry.data[CONF_VERIFY_SSL], session=session, + public_api_session=public_api_session, subscribed_models=DEVICES_FOR_SUBSCRIBE, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index c49ade514bc..895ba62f81a 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -32,6 +32,7 @@ from uiprotect.data import ( from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -68,6 +69,7 @@ def mock_ufp_config_entry(): "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 13e93a8c2e7..dc841ab7a1e 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -5,7 +5,7 @@ "canAutoUpdate": true, "isStatsGatheringEnabled": true, "timezone": "America/New_York", - "version": "2.2.6", + "version": "6.0.0", "ucoreVersion": "2.3.26", "firmwareVersion": "2.3.10", "uiVersion": null, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 880578719cd..a5cda887b4d 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -74,6 +74,10 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -89,6 +93,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -99,6 +104,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -116,9 +122,15 @@ async def test_form_version_too_old( ) bootstrap.nvr = old_nvr - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -126,6 +138,7 @@ async def test_form_version_too_old( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -133,15 +146,21 @@ async def test_form_version_too_old( assert result2["errors"] == {"base": "protect_version"} -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +async def test_form_invalid_auth_password(hass: HomeAssistant) -> None: + """Test we handle invalid auth password.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -149,6 +168,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -156,6 +176,38 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: assert result2["errors"] == {"password": "invalid_auth"} +async def test_form_invalid_auth_api_key( + hass: HomeAssistant, bootstrap: Bootstrap +) -> None: + """Test we handle invalid auth api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NotAuthorized, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"api_key": "invalid_auth"} + + async def test_form_cloud_user( hass: HomeAssistant, bootstrap: Bootstrap, cloud_account: CloudAccount ) -> None: @@ -167,9 +219,15 @@ async def test_form_cloud_user( user = bootstrap.users[bootstrap.auth_user_id] user.cloud_account = cloud_account bootstrap.users[bootstrap.auth_user_id] = user - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -177,6 +235,7 @@ async def test_form_cloud_user( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -190,9 +249,15 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NvrError, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NvrError, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NvrError, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -200,6 +265,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -217,6 +283,7 @@ async def test_form_reauth_auth( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -234,15 +301,22 @@ async def test_form_reauth_auth( "name": "Mock Title", } - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -260,12 +334,17 @@ async def test_form_reauth_auth( "homeassistant.components.unifiprotect.async_setup", return_value=True, ) as mock_setup, + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { "username": "test-username", "password": "new-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -283,6 +362,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -383,6 +463,10 @@ async def test_discovered_by_unifi_discovery_direct_connect( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -397,6 +481,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -407,6 +492,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -425,6 +511,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -583,6 +670,10 @@ async def test_discovered_by_unifi_discovery( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", side_effect=[NotAuthorized, bootstrap], ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -597,6 +688,7 @@ async def test_discovered_by_unifi_discovery( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -607,6 +699,7 @@ async def test_discovered_by_unifi_discovery( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -644,6 +737,10 @@ async def test_discovered_by_unifi_discovery_partial( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -658,6 +755,7 @@ async def test_discovered_by_unifi_discovery_partial( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -668,6 +766,7 @@ async def test_discovered_by_unifi_discovery_partial( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -686,6 +785,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -716,6 +816,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "127.0.0.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -746,6 +847,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -787,6 +889,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -827,6 +930,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -841,6 +948,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -851,6 +959,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "nomatchsameip.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -869,6 +978,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 3156327f1a5..b951d95fbdc 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.unifiprotect.data import ( async_ufp_instance_for_config_entry_ids, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -29,6 +30,19 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +@pytest.fixture +def mock_user_can_write_nvr(request: pytest.FixtureRequest, ufp: MockUFPFixture): + """Fixture to mock can_write method on NVR objects with indirect parametrization.""" + can_write_result = getattr(request, "param", True) + original_can_write = ufp.api.bootstrap.nvr.can_write + mock_can_write = Mock(return_value=can_write_result) + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", mock_can_write) + try: + yield mock_can_write + finally: + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", original_can_write) + + async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture) -> None: """Test working setup of unifiprotect entry.""" @@ -68,6 +82,7 @@ async def test_setup_multiple( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -331,6 +346,112 @@ async def test_async_ufp_instance_for_config_entry_ids( assert result == expected_result +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_creates_api_key_when_missing( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key is created when missing and user has write permissions.""" + # Setup: API key is not set initially, user has write permissions + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") + + # Mock set_api_key to update is_api_key_set return value when called + def set_api_key_side_effect(key): + ufp.api.is_api_key_set.return_value = True + + ufp.api.set_api_key.side_effect = set_api_key_side_effect + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify API key was created and set + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") + + # Verify config entry was updated with new API key + assert ufp.entry.data[CONF_API_KEY] == "new-api-key-123" + assert ufp.entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [False], indirect=True) +async def test_setup_skips_api_key_creation_when_no_write_permission( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key creation is skipped when user has no write permissions.""" + # Setup: API key is not set, user has no write permissions + ufp.api.is_api_key_set.return_value = False + + # Should fail with auth error since no API key and can't create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_handles_api_key_creation_failure( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling of API key creation failure.""" + # Setup: API key is not set, user has write permissions, but creation fails + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock( + side_effect=NotAuthorized("Failed to create API key") + ) + + # Should fail with auth error due to API key creation failure + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted but set_api_key was not called + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_not_called() + + +async def test_setup_with_existing_api_key( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test setup when API key is already set.""" + # Setup: API key is already set + ufp.api.is_api_key_set.return_value = True + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.LOADED + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_api_key_creation_returns_none( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling when API key creation returns None.""" + # Setup: API key is not set, creation returns None (empty response) + # set_api_key will be called with None but is_api_key_set will still be False + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value=None) + + # Should fail with auth error since API key creation returned None + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted and set_api_key was called with None + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with(None) + + async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: """Test remove CONF_ALLOW_EA from options while migrating a 1 config entry to 2.""" with ( @@ -350,3 +471,47 @@ async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: assert entry.version == 2 assert entry.options.get(CONF_ALLOW_EA) is None assert entry.unique_id == "123456" + + +async def test_setup_skips_api_key_creation_when_no_auth_user( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test that API key creation is skipped when auth_user is None.""" + # Setup: API key is not set, auth_user is None + ufp.api.is_api_key_set.return_value = False + + # Mock the users dictionary to return None for any user ID + with patch.dict(ufp.api.bootstrap.users, {}, clear=True): + # Should fail with auth error since no API key and no auth user to create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_fails_when_api_key_still_missing_after_creation( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that setup fails when API key is still missing after creation attempts.""" + # Setup: API key is not set and remains not set even after attempts + ufp.api.is_api_key_set.return_value = False # type: ignore[attr-defined] + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") # type: ignore[method-assign] + ufp.api.set_api_key = Mock() # type: ignore[method-assign] # Mock this but API key still won't be "set" + + # Setup should fail since API key is still not set after creation + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify entry is in setup error state (which will trigger reauth automatically) + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted + ufp.api.create_api_key.assert_called_once_with( # type: ignore[attr-defined] + name="Home Assistant (test home)" + ) + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") # type: ignore[attr-defined] diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 61f9680bdbc..02d07bb1d4d 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -234,6 +234,7 @@ async def test_browse_media_root_multiple_consoles( "host": "1.1.1.2", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect2", "port": 443, "verify_ssl": False, From c4d742f549bf44c60f7e60edd8b892041868bd0b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 11:01:19 +0200 Subject: [PATCH 045/235] Add missing hyphen to "auto-renew period" in `whois` (#149296) --- homeassistant/components/whois/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index b236bb06208..814b952d417 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -52,7 +52,7 @@ "name": "Status", "state": { "add_period": "Add period", - "auto_renew_period": "Auto renew period", + "auto_renew_period": "Auto-renew period", "inactive": "Inactive", "ok": "Active", "active": "Active", From 7aa4810b0abbab39fa0296ae10e7d8b8c24e6dee Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:26:54 +0200 Subject: [PATCH 046/235] Clean up internal_get_tts_audio in TTS entity (#148946) --- homeassistant/components/tts/entity.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index dc6f22570fc..aea5be6d0da 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -165,18 +165,6 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH self.async_write_ha_state() return await self.async_stream_tts_audio(request) - @final - async def async_internal_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine and update state. - - Return a tuple of file extension and data as bytes. - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio(message, language, options=options) - async def async_stream_tts_audio( self, request: TTSAudioRequest ) -> TTSAudioResponse: From 52abab8ae812854e104f007304dca77a5efa64e4 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Wed, 23 Jul 2025 11:28:28 +0200 Subject: [PATCH 047/235] Use translation_key for entities in Huum (#149256) --- homeassistant/components/huum/binary_sensor.py | 1 - homeassistant/components/huum/light.py | 2 +- homeassistant/components/huum/strings.json | 7 +++++++ tests/components/huum/snapshots/test_light.ambr | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py index a8e094dda94..7bc03e9fe94 100644 --- a/homeassistant/components/huum/binary_sensor.py +++ b/homeassistant/components/huum/binary_sensor.py @@ -27,7 +27,6 @@ async def async_setup_entry( class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): """Representation of a BinarySensor.""" - _attr_name = "Door" _attr_device_class = BinarySensorDeviceClass.DOOR def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py index 8eb35afdda2..9d3ec54101d 100644 --- a/homeassistant/components/huum/light.py +++ b/homeassistant/components/huum/light.py @@ -32,7 +32,7 @@ async def async_setup_entry( class HuumLight(HuumBaseEntity, LightEntity): """Representation of a light.""" - _attr_name = "Light" + _attr_translation_key = "light" _attr_supported_color_modes = {ColorMode.ONOFF} _attr_color_mode = ColorMode.ONOFF diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 68ab1adde6f..55ccf0fdd81 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -18,5 +18,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + } } } diff --git a/tests/components/huum/snapshots/test_light.ambr b/tests/components/huum/snapshots/test_light.ambr index 918210272b2..da449c16fe8 100644 --- a/tests/components/huum/snapshots/test_light.ambr +++ b/tests/components/huum/snapshots/test_light.ambr @@ -33,7 +33,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'AABBCC112233', 'unit_of_measurement': None, }) From aeeabfcae726b06ee00d525f4849df41912e1ce9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 11:55:35 +0200 Subject: [PATCH 048/235] Fix typo "hazlenut" in `miele` (#149299) --- homeassistant/components/miele/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 18893c238fe..2ae412ed95e 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -462,8 +462,8 @@ "chicken_tikka_masala_with_rice": "Chicken Tikka Masala with rice", "chicken_whole": "Chicken", "chinese_cabbage_cut": "Chinese cabbage (cut)", - "chocolate_hazlenut_cake_one_large": "Chocolate hazlenut cake (one large)", - "chocolate_hazlenut_cake_several_small": "Chocolate hazlenut cake (several small)", + "chocolate_hazlenut_cake_one_large": "Chocolate hazelnut cake (one large)", + "chocolate_hazlenut_cake_several_small": "Chocolate hazelnut cake (several small)", "chongming_rapid_steam_cooking": "Chongming (rapid steam cooking)", "chongming_steam_cooking": "Chongming (steam cooking)", "choux_buns": "Choux buns", From 232b34609ca2306ad512b50bbd804637b8e0be94 Mon Sep 17 00:00:00 2001 From: David Ferguson Date: Wed, 23 Jul 2025 06:37:47 -0400 Subject: [PATCH 049/235] Avoid hardcoded max core climate timeout in SleepIQ (#149283) --- homeassistant/components/sleepiq/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index ffbcbe7a970..1a99f47c38c 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -164,7 +164,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( key=CORE_CLIMATE_TIMER, native_min_value=0, - native_max_value=600, + native_max_value=SleepIQCoreClimate.max_core_climate_time, native_step=30, name=ENTITY_TYPES[CORE_CLIMATE_TIMER], icon="mdi:timer", From b37273ed33688258c1ef77e6e682ffc6b8c4bdc4 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 23 Jul 2025 12:39:17 +0200 Subject: [PATCH 050/235] Makes entites available in Husqvarna Automower when mower is in error state (#149261) --- .../components/husqvarna_automower/button.py | 9 ++---- .../components/husqvarna_automower/entity.py | 28 +++++-------------- .../husqvarna_automower/lawn_mower.py | 4 +-- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 281669aad04..8e58a309e59 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -14,11 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator -from .entity import ( - AutomowerAvailableEntity, - _check_error_free, - handle_sending_exception, -) +from .entity import AutomowerControlEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) @@ -45,7 +41,6 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( AutomowerButtonEntityDescription( key="sync_clock", translation_key="sync_clock", - available_fn=_check_error_free, press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), ), ) @@ -71,7 +66,7 @@ async def async_setup_entry( _async_add_new_devices(set(coordinator.data)) -class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): +class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): """Defining the AutomowerButtonEntity.""" entity_description: AutomowerButtonEntityDescription diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 3ccb098262f..99df51c7fe7 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -37,15 +37,6 @@ ERROR_STATES = [ ] -@callback -def _check_error_free(mower_attributes: MowerAttributes) -> bool: - """Check if the mower has any errors.""" - return ( - mower_attributes.mower.state not in ERROR_STATES - or mower_attributes.mower.activity not in ERROR_ACTIVITIES - ) - - @callback def _work_area_translation_key(work_area_id: int, key: str) -> str: """Return the translation key.""" @@ -120,25 +111,20 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): return super().available and self.mower_id in self.coordinator.data -class AutomowerAvailableEntity(AutomowerBaseEntity): +class AutomowerControlEntity(AutomowerBaseEntity): """Replies available when the mower is connected.""" @property def available(self) -> bool: """Return True if the device is available.""" - return super().available and self.mower_attributes.metadata.connected + return ( + super().available + and self.mower_attributes.metadata.connected + and self.mower_attributes.mower.state != MowerStates.OFF + ) -class AutomowerControlEntity(AutomowerAvailableEntity): - """Replies available when the mower is connected and not in error state.""" - - @property - def available(self) -> bool: - """Return True if the device is available.""" - return super().available and _check_error_free(self.mower_attributes) - - -class WorkAreaAvailableEntity(AutomowerAvailableEntity): +class WorkAreaAvailableEntity(AutomowerControlEntity): """Base entity for work areas.""" def __init__( diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index daeb4a113b5..df312ae4ffd 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .const import DOMAIN, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerAvailableEntity, handle_sending_exception +from .entity import AutomowerBaseEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) @@ -89,7 +89,7 @@ async def async_setup_entry( ) -class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): +class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity): """Defining each mower Entity.""" _attr_name = None From 3dffd74607e7712cdeecfba85a57d560adbba944 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Jul 2025 12:58:15 +0200 Subject: [PATCH 051/235] Migrate OpenAI to has entity name (#149301) --- homeassistant/components/openai_conversation/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 93713c78d9c..c1b2f970f07 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -274,11 +274,13 @@ async def _transform_stream( class OpenAIBaseLLMEntity(Entity): """OpenAI conversation agent.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, From eb8ca53a037165994d51ca0ecba8ca94d259f9b7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Jul 2025 12:58:28 +0200 Subject: [PATCH 052/235] Migrate Anthropic to has entity name (#149302) --- homeassistant/components/anthropic/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index a28c948d28b..636417dd43b 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -311,11 +311,13 @@ def _create_token_stats( class AnthropicBaseLLMEntity(Entity): """Anthropic base LLM entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, From edf6166a9f81d8569934c49c2c9a1761953cad09 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 13:58:24 +0200 Subject: [PATCH 053/235] Fix spelling of "Domino's Pizza" in `dominos` (#149308) --- homeassistant/components/dominos/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dominos/strings.json b/homeassistant/components/dominos/strings.json index 0ceabd7abe8..5d95be478ce 100644 --- a/homeassistant/components/dominos/strings.json +++ b/homeassistant/components/dominos/strings.json @@ -2,11 +2,11 @@ "services": { "order": { "name": "Order", - "description": "Places a set of orders with Dominos Pizza.", + "description": "Places a set of orders with Domino's Pizza.", "fields": { "order_entity_id": { "name": "Order entity", - "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed." + "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all the identified orders will be placed." } } } From dcf29d12a73512aeb705d4b6336de1c9b426452e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Jul 2025 14:27:32 +0200 Subject: [PATCH 054/235] Migrate Ollama to has entity name (#149303) --- homeassistant/components/ollama/entity.py | 4 +++- tests/components/ollama/test_conversation.py | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index b2f0ebbb7b8..2581698e185 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -170,11 +170,13 @@ async def _transform_stream( class OllamaBaseLLMEntity(Entity): """Ollama base LLM entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id model, _, version = subentry.data[CONF_MODEL].partition(":") diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index f7e50d61e2c..4904829a31c 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -619,7 +619,6 @@ async def test_conversation_agent( assert entity_entry subentry = mock_config_entry.subentries.get(entity_entry.unique_id) assert subentry - assert entity_entry.original_name == subentry.title device_entry = device_registry.async_get(entity_entry.device_id) assert device_entry From 2a0a31bff8b2bb0b5c2703bb47f042d9be75cbac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 14:27:49 +0200 Subject: [PATCH 055/235] Capitalize "HEPA" as an abbreviation in `matter` (#149306) --- homeassistant/components/matter/strings.json | 2 +- tests/components/matter/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 20d7eb69ba4..7f603c9d188 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -310,7 +310,7 @@ "name": "Flow" }, "hepa_filter_condition": { - "name": "Hepa filter condition" + "name": "HEPA filter condition" }, "operational_state": { "name": "Operational state", diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 140384283cc..bff4ad7909d 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -250,7 +250,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -263,7 +263,7 @@ # name: test_sensors[air_purifier][sensor.air_purifier_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier Hepa filter condition', + 'friendly_name': 'Air Purifier HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), @@ -2985,7 +2985,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2998,7 +2998,7 @@ # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Extractor hood Hepa filter condition', + 'friendly_name': 'Mock Extractor hood HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), From 47611619db4d1c35b09f3f2b64a08e35c60f362a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 23 Jul 2025 22:45:50 +1000 Subject: [PATCH 056/235] Update Tesla OAuth Server in Tesla Fleet (#149280) --- homeassistant/components/tesla_fleet/const.py | 5 ++--- tests/components/tesla_fleet/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index d73234b1fdd..761bbebf7a8 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -14,9 +14,8 @@ CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) -CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d" -AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize" -TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token" +AUTHORIZE_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize" +TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" SCOPES = [ Scope.OPENID, diff --git a/tests/components/tesla_fleet/__init__.py b/tests/components/tesla_fleet/__init__.py index c51cd83ee66..a43ec14fc51 100644 --- a/tests/components/tesla_fleet/__init__.py +++ b/tests/components/tesla_fleet/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.tesla_fleet.const import CLIENT_ID, DOMAIN +from homeassistant.components.tesla_fleet.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,7 +28,7 @@ async def setup_platform( await async_import_client_credential( hass, DOMAIN, - ClientCredential(CLIENT_ID, "", "Home Assistant"), + ClientCredential("CLIENT_ID", "CLIENT_SECRET", "Home Assistant"), DOMAIN, ) From 6dc5c9beb7839763ec507a8fba3f1f7dba5a2d9d Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Wed, 23 Jul 2025 08:52:14 -0400 Subject: [PATCH 057/235] Add fan off mode to the supported fan modes to fujitsu_fglair (#149277) --- homeassistant/components/fujitsu_fglair/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index bf1df07823c..85ef119a583 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( FAN_HIGH, FAN_LOW, FAN_MEDIUM, + FAN_OFF, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -31,6 +32,7 @@ from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity HA_TO_FUJI_FAN = { + FAN_OFF: FanSpeed.QUIET, FAN_LOW: FanSpeed.LOW, FAN_MEDIUM: FanSpeed.MEDIUM, FAN_HIGH: FanSpeed.HIGH, From 4d5c1b139bc2fe6c7353189c8230132593a29b2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Jul 2025 02:57:07 -1000 Subject: [PATCH 058/235] Consolidate REST sensor encoding tests using pytest parametrize (#149279) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/rest/test_sensor.py | 108 +++++++++++---------------- 1 file changed, 42 insertions(+), 66 deletions(-) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index b830d6b7743..7bd84bbcd70 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -144,14 +144,49 @@ async def test_setup_minimum( assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 -async def test_setup_encoding( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize( + ("content_text", "content_encoding", "headers", "expected_state"), + [ + # Test setup with non-utf8 encoding + pytest.param( + "tack själv", + "iso-8859-1", + None, + "tack själv", + id="simple_iso88591", + ), + # Test that configured encoding is used when no charset in Content-Type + pytest.param( + "Björk Guðmundsdóttir", + "iso-8859-1", + {"Content-Type": "text/plain"}, # No charset! + "Björk Guðmundsdóttir", + id="fallback_when_no_charset", + ), + # Test that charset in Content-Type overrides configured encoding + pytest.param( + "Björk Guðmundsdóttir", + "utf-8", + {"Content-Type": "text/plain; charset=utf-8"}, + "Björk Guðmundsdóttir", + id="charset_overrides_config", + ), + ], +) +async def test_setup_with_encoding_config( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + content_text: str, + content_encoding: str, + headers: dict[str, str] | None, + expected_state: str, ) -> None: - """Test setup with non-utf8 encoding.""" + """Test setup with encoding configuration in sensor config.""" aioclient_mock.get( "http://localhost", status=HTTPStatus.OK, - content="tack själv".encode(encoding="iso-8859-1"), + content=content_text.encode(content_encoding), + headers=headers, ) assert await async_setup_component( hass, @@ -168,10 +203,10 @@ async def test_setup_encoding( ) await hass.async_block_till_done() assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "tack själv" + assert hass.states.get("sensor.mysensor").state == expected_state -async def test_setup_auto_encoding_from_content_type( +async def test_setup_with_charset_from_header( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with encoding auto-detected from Content-Type header.""" @@ -188,7 +223,7 @@ async def test_setup_auto_encoding_from_content_type( { SENSOR_DOMAIN: { "name": "mysensor", - # encoding defaults to UTF-8, but should be ignored when charset present + # No encoding config - should use charset from header. "platform": DOMAIN, "resource": "http://localhost", "method": "GET", @@ -200,65 +235,6 @@ async def test_setup_auto_encoding_from_content_type( assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" -async def test_setup_encoding_fallback_no_charset( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that configured encoding is used when no charset in Content-Type.""" - # No charset in Content-Type header - aioclient_mock.get( - "http://localhost", - status=HTTPStatus.OK, - content="Björk Guðmundsdóttir".encode("iso-8859-1"), - headers={"Content-Type": "text/plain"}, # No charset! - ) - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "name": "mysensor", - "encoding": "iso-8859-1", # This will be used as fallback - "platform": DOMAIN, - "resource": "http://localhost", - "method": "GET", - } - }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" - - -async def test_setup_charset_overrides_encoding_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that charset in Content-Type overrides configured encoding.""" - # Server sends UTF-8 with correct charset header - aioclient_mock.get( - "http://localhost", - status=HTTPStatus.OK, - content="Björk Guðmundsdóttir".encode(), - headers={"Content-Type": "text/plain; charset=utf-8"}, - ) - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "name": "mysensor", - "encoding": "iso-8859-1", # Config says ISO-8859-1, but charset=utf-8 should win - "platform": DOMAIN, - "resource": "http://localhost", - "method": "GET", - } - }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - # This should work because charset=utf-8 overrides the iso-8859-1 config - assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" - - @pytest.mark.parametrize( ("ssl_cipher_list", "ssl_cipher_list_expected"), [ From 70e03cdd4ebd25c9cda6d10ab000e5df160bcb27 Mon Sep 17 00:00:00 2001 From: johanzander Date: Wed, 23 Jul 2025 15:05:19 +0200 Subject: [PATCH 059/235] Implements coordinator pattern for Growatt component data fetching (#143373) --- .../components/growatt_server/__init__.py | 101 +++++- .../components/growatt_server/coordinator.py | 210 +++++++++++ .../components/growatt_server/models.py | 17 + .../growatt_server/sensor/__init__.py | 335 +++--------------- 4 files changed, 372 insertions(+), 291 deletions(-) create mode 100644 homeassistant/components/growatt_server/coordinator.py create mode 100644 homeassistant/components/growatt_server/models.py diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 66df76bc6cb..39270788780 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -1,21 +1,104 @@ """The Growatt server PV inverter sensor integration.""" -from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from collections.abc import Mapping -from .const import PLATFORMS +import growattServer + +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import ( + CONF_PLANT_ID, + DEFAULT_PLANT_ID, + DEFAULT_URL, + DEPRECATED_URLS, + LOGIN_INVALID_AUTH_CODE, + PLATFORMS, +) +from .coordinator import GrowattConfigEntry, GrowattCoordinator +from .models import GrowattRuntimeData + + +def get_device_list( + api: growattServer.GrowattApi, config: Mapping[str, str] +) -> tuple[list[dict[str, str]], str]: + """Retrieve the device list for the selected plant.""" + plant_id = config[CONF_PLANT_ID] + + # Log in to api and fetch first plant if no plant id is defined. + login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): + raise ConfigEntryError("Username, Password or URL may be incorrect!") + user_id = login_response["user"]["id"] + if plant_id == DEFAULT_PLANT_ID: + plant_info = api.plant_list(user_id) + plant_id = plant_info["data"][0]["plantId"] + + # Get a list of devices for specified plant to add sensors for. + devices = api.device_list(plant_id) + return devices, plant_id async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry + hass: HomeAssistant, config_entry: GrowattConfigEntry ) -> bool: - """Load the saved entities.""" + """Set up Growatt from a config entry.""" + config = config_entry.data + username = config[CONF_USERNAME] + url = config.get(CONF_URL, DEFAULT_URL) + + # If the URL has been deprecated then change to the default instead + if url in DEPRECATED_URLS: + url = DEFAULT_URL + new_data = dict(config_entry.data) + new_data[CONF_URL] = url + hass.config_entries.async_update_entry(config_entry, data=new_data) + + # Initialise the library with the username & a random id each time it is started + api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) + api.server_url = url + + devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) + + # Create a coordinator for the total sensors + total_coordinator = GrowattCoordinator( + hass, config_entry, plant_id, "total", plant_id + ) + + # Create coordinators for each device + device_coordinators = { + device["deviceSn"]: GrowattCoordinator( + hass, config_entry, device["deviceSn"], device["deviceType"], plant_id + ) + for device in devices + if device["deviceType"] in ["inverter", "tlx", "storage", "mix"] + } + + # Perform the first refresh for the total coordinator + await total_coordinator.async_config_entry_first_refresh() + + # Perform the first refresh for each device coordinator + for device_coordinator in device_coordinators.values(): + await device_coordinator.async_config_entry_first_refresh() + + # Store runtime data in the config entry + config_entry.runtime_data = GrowattRuntimeData( + total_coordinator=total_coordinator, + devices=device_coordinators, + ) + + # Set up all the entities + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: GrowattConfigEntry +) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py new file mode 100644 index 00000000000..a1a2fb938f0 --- /dev/null +++ b/homeassistant/components/growatt_server/coordinator.py @@ -0,0 +1,210 @@ +"""Coordinator module for managing Growatt data fetching.""" + +import datetime +import json +import logging +from typing import TYPE_CHECKING, Any + +import growattServer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_URL, DOMAIN +from .models import GrowattRuntimeData + +if TYPE_CHECKING: + from .sensor.sensor_entity_description import GrowattSensorEntityDescription + +type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData] + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator to manage Growatt data fetching.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GrowattConfigEntry, + device_id: str, + device_type: str, + plant_id: str, + ) -> None: + """Initialize the coordinator.""" + self.username = config_entry.data[CONF_USERNAME] + self.password = config_entry.data[CONF_PASSWORD] + self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) + self.api = growattServer.GrowattApi( + add_random_user_id=True, agent_identifier=self.username + ) + + # Set server URL + self.api.server_url = self.url + + self.device_id = device_id + self.device_type = device_type + self.plant_id = plant_id + + # Initialize previous_values to store historical data + self.previous_values: dict[str, Any] = {} + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} ({device_id})", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + def _sync_update_data(self) -> dict[str, Any]: + """Update data via library synchronously.""" + _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) + + # Login in to the Growatt server + self.api.login(self.username, self.password) + + if self.device_type == "total": + total_info = self.api.plant_info(self.device_id) + del total_info["deviceList"] + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency + self.data = total_info + elif self.device_type == "inverter": + self.data = self.api.inverter_detail(self.device_id) + elif self.device_type == "tlx": + tlx_info = self.api.tlx_detail(self.device_id) + self.data = tlx_info["data"] + elif self.device_type == "storage": + storage_info_detail = self.api.storage_params(self.device_id) + storage_energy_overview = self.api.storage_energy_overview( + self.plant_id, self.device_id + ) + self.data = { + **storage_info_detail["storageDetailBean"], + **storage_energy_overview, + } + elif self.device_type == "mix": + mix_info = self.api.mix_info(self.device_id) + mix_totals = self.api.mix_totals(self.device_id, self.plant_id) + mix_system_status = self.api.mix_system_status( + self.device_id, self.plant_id + ) + mix_detail = self.api.mix_detail(self.device_id, self.plant_id) + + # Get the chart data and work out the time of the last entry + mix_chart_entries = mix_detail["chartData"] + sorted_keys = sorted(mix_chart_entries) + + # Create datetime from the latest entry + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) + mix_detail["lastdataupdate"] = datetime.datetime.combine( + date_now, + last_updated_time, # type: ignore[arg-type] + dt_util.get_default_time_zone(), + ) + + # Dashboard data for mix system + dashboard_data = self.api.dashboard_data(self.plant_id) + dashboard_values_for_mix = { + "etouser_combined": float(dashboard_data["etouser"].replace("kWh", "")) + } + self.data = { + **mix_info, + **mix_totals, + **mix_system_status, + **mix_detail, + **dashboard_values_for_mix, + } + _LOGGER.debug( + "Finished updating data for %s (%s)", + self.device_id, + self.device_type, + ) + + return self.data + + async def _async_update_data(self) -> dict[str, Any]: + """Asynchronously update data via library.""" + try: + return await self.hass.async_add_executor_job(self._sync_update_data) + except json.decoder.JSONDecodeError as err: + _LOGGER.error("Unable to fetch data from Growatt server: %s", err) + raise UpdateFailed(f"Error fetching data: {err}") from err + + def get_currency(self): + """Get the currency.""" + return self.data.get("currency") + + def get_data( + self, entity_description: "GrowattSensorEntityDescription" + ) -> str | int | float | None: + """Get the data.""" + variable = entity_description.api_key + api_value = self.data.get(variable) + previous_value = self.previous_values.get(variable) + return_value = api_value + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + entity_description.previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + entity_description.name, + entity_description.previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(entity_description.previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug( + "%s - No drop detected, using API value", entity_description.name + ) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + if entity_description.never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return return_value diff --git a/homeassistant/components/growatt_server/models.py b/homeassistant/components/growatt_server/models.py new file mode 100644 index 00000000000..8c5f409616a --- /dev/null +++ b/homeassistant/components/growatt_server/models.py @@ -0,0 +1,17 @@ +"""Models for the Growatt server integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .coordinator import GrowattCoordinator + + +@dataclass +class GrowattRuntimeData: + """Runtime data for the Growatt integration.""" + + total_coordinator: GrowattCoordinator + devices: dict[str, GrowattCoordinator] diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index 2794403811d..3a78f26f091 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -2,29 +2,16 @@ from __future__ import annotations -import datetime -import json import logging -import growattServer - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ..const import ( - CONF_PLANT_ID, - DEFAULT_PLANT_ID, - DEFAULT_URL, - DEPRECATED_URLS, - DOMAIN, - LOGIN_INVALID_AUTH_CODE, -) +from ..const import DOMAIN +from ..coordinator import GrowattConfigEntry, GrowattCoordinator from .inverter import INVERTER_SENSOR_TYPES from .mix import MIX_SENSOR_TYPES from .sensor_entity_description import GrowattSensorEntityDescription @@ -34,136 +21,97 @@ from .total import TOTAL_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(minutes=5) - - -def get_device_list(api, config): - """Retrieve the device list for the selected plant.""" - plant_id = config[CONF_PLANT_ID] - - # Log in to api and fetch first plant if no plant id is defined. - login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - if ( - not login_response["success"] - and login_response["msg"] == LOGIN_INVALID_AUTH_CODE - ): - raise ConfigEntryError("Username, Password or URL may be incorrect!") - user_id = login_response["user"]["id"] - if plant_id == DEFAULT_PLANT_ID: - plant_info = api.plant_list(user_id) - plant_id = plant_info["data"][0]["plantId"] - - # Get a list of devices for specified plant to add sensors for. - devices = api.device_list(plant_id) - return [devices, plant_id] - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GrowattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Growatt sensor.""" - config = {**config_entry.data} - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - url = config.get(CONF_URL, DEFAULT_URL) - name = config[CONF_NAME] + # Use runtime_data instead of hass.data + data = config_entry.runtime_data - # If the URL has been deprecated then change to the default instead - if url in DEPRECATED_URLS: - _LOGGER.warning( - "URL: %s has been deprecated, migrating to the latest default: %s", - url, - DEFAULT_URL, - ) - url = DEFAULT_URL - config[CONF_URL] = url - hass.config_entries.async_update_entry(config_entry, data=config) + entities: list[GrowattSensor] = [] - # Initialise the library with the username & a random id each time it is started - api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) - api.server_url = url - - devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) - - probe = GrowattData(api, username, password, plant_id, "total") - entities = [ - GrowattInverter( - probe, - name=f"{name} Total", - unique_id=f"{plant_id}-{description.key}", + # Add total sensors + total_coordinator = data.total_coordinator + entities.extend( + GrowattSensor( + total_coordinator, + name=f"{config_entry.data['name']} Total", + serial_id=config_entry.data["plant_id"], + unique_id=f"{config_entry.data['plant_id']}-{description.key}", description=description, ) for description in TOTAL_SENSOR_TYPES - ] + ) - # Add sensors for each device in the specified plant. - for device in devices: - probe = GrowattData( - api, username, password, device["deviceSn"], device["deviceType"] - ) - sensor_descriptions: tuple[GrowattSensorEntityDescription, ...] = () - if device["deviceType"] == "inverter": - sensor_descriptions = INVERTER_SENSOR_TYPES - elif device["deviceType"] == "tlx": - probe.plant_id = plant_id - sensor_descriptions = TLX_SENSOR_TYPES - elif device["deviceType"] == "storage": - probe.plant_id = plant_id - sensor_descriptions = STORAGE_SENSOR_TYPES - elif device["deviceType"] == "mix": - probe.plant_id = plant_id - sensor_descriptions = MIX_SENSOR_TYPES + # Add sensors for each device + for device_sn, device_coordinator in data.devices.items(): + sensor_descriptions: list = [] + if device_coordinator.device_type == "inverter": + sensor_descriptions = list(INVERTER_SENSOR_TYPES) + elif device_coordinator.device_type == "tlx": + sensor_descriptions = list(TLX_SENSOR_TYPES) + elif device_coordinator.device_type == "storage": + sensor_descriptions = list(STORAGE_SENSOR_TYPES) + elif device_coordinator.device_type == "mix": + sensor_descriptions = list(MIX_SENSOR_TYPES) else: _LOGGER.debug( "Device type %s was found but is not supported right now", - device["deviceType"], + device_coordinator.device_type, ) entities.extend( - [ - GrowattInverter( - probe, - name=f"{device['deviceAilas']}", - unique_id=f"{device['deviceSn']}-{description.key}", - description=description, - ) - for description in sensor_descriptions - ] + GrowattSensor( + device_coordinator, + name=device_sn, + serial_id=device_sn, + unique_id=f"{device_sn}-{description.key}", + description=description, + ) + for description in sensor_descriptions ) - async_add_entities(entities, True) + async_add_entities(entities) -class GrowattInverter(SensorEntity): +class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity): """Representation of a Growatt Sensor.""" _attr_has_entity_name = True - entity_description: GrowattSensorEntityDescription def __init__( - self, probe, name, unique_id, description: GrowattSensorEntityDescription + self, + coordinator: GrowattCoordinator, + name: str, + serial_id: str, + unique_id: str, + description: GrowattSensorEntityDescription, ) -> None: """Initialize a PVOutput sensor.""" - self.probe = probe + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = unique_id self._attr_icon = "mdi:solar-power" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, probe.device_id)}, + identifiers={(DOMAIN, serial_id)}, manufacturer="Growatt", name=name, ) @property - def native_value(self): + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" - result = self.probe.get_data(self.entity_description) - if self.entity_description.precision is not None: + result = self.coordinator.get_data(self.entity_description) + if ( + isinstance(result, (int, float)) + and self.entity_description.precision is not None + ): result = round(result, self.entity_description.precision) return result @@ -171,182 +119,5 @@ class GrowattInverter(SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" if self.entity_description.currency: - return self.probe.get_currency() + return self.coordinator.get_currency() return super().native_unit_of_measurement - - def update(self) -> None: - """Get the latest data from the Growat API and updates the state.""" - self.probe.update() - - -class GrowattData: - """The class for handling data retrieval.""" - - def __init__(self, api, username, password, device_id, growatt_type): - """Initialize the probe.""" - - self.growatt_type = growatt_type - self.api = api - self.device_id = device_id - self.plant_id = None - self.data = {} - self.previous_values = {} - self.username = username - self.password = password - - @Throttle(SCAN_INTERVAL) - def update(self): - """Update probe data.""" - self.api.login(self.username, self.password) - _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type) - try: - if self.growatt_type == "total": - total_info = self.api.plant_info(self.device_id) - del total_info["deviceList"] - # PlantMoneyText comes in as "3.1/€" split between value and currency - plant_money_text, currency = total_info["plantMoneyText"].split("/") - total_info["plantMoneyText"] = plant_money_text - total_info["currency"] = currency - self.data = total_info - elif self.growatt_type == "inverter": - inverter_info = self.api.inverter_detail(self.device_id) - self.data = inverter_info - elif self.growatt_type == "tlx": - tlx_info = self.api.tlx_detail(self.device_id) - self.data = tlx_info["data"] - elif self.growatt_type == "storage": - storage_info_detail = self.api.storage_params(self.device_id)[ - "storageDetailBean" - ] - storage_energy_overview = self.api.storage_energy_overview( - self.plant_id, self.device_id - ) - self.data = {**storage_info_detail, **storage_energy_overview} - elif self.growatt_type == "mix": - mix_info = self.api.mix_info(self.device_id) - mix_totals = self.api.mix_totals(self.device_id, self.plant_id) - mix_system_status = self.api.mix_system_status( - self.device_id, self.plant_id - ) - - mix_detail = self.api.mix_detail(self.device_id, self.plant_id) - # Get the chart data and work out the time of the last entry, use this - # as the last time data was published to the Growatt Server - mix_chart_entries = mix_detail["chartData"] - sorted_keys = sorted(mix_chart_entries) - - # Create datetime from the latest entry - date_now = dt_util.now().date() - last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) - mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt_util.get_default_time_zone() - ) - - # Dashboard data is largely inaccurate for mix system but it is the only - # call with the ability to return the combined imported from grid value - # that is the combination of charging AND load consumption - dashboard_data = self.api.dashboard_data(self.plant_id) - # Dashboard values have units e.g. "kWh" as part of their returned - # string, so we remove it - dashboard_values_for_mix = { - # etouser is already used by the results from 'mix_detail' so we - # rebrand it as 'etouser_combined' - "etouser_combined": float( - dashboard_data["etouser"].replace("kWh", "") - ) - } - self.data = { - **mix_info, - **mix_totals, - **mix_system_status, - **mix_detail, - **dashboard_values_for_mix, - } - _LOGGER.debug( - "Finished updating data for %s (%s)", - self.device_id, - self.growatt_type, - ) - except json.decoder.JSONDecodeError: - _LOGGER.error("Unable to fetch data from Growatt server") - - def get_currency(self): - """Get the currency.""" - return self.data.get("currency") - - def get_data(self, entity_description): - """Get the data.""" - _LOGGER.debug( - "Data request for: %s", - entity_description.name, - ) - variable = entity_description.api_key - api_value = self.data.get(variable) - previous_value = self.previous_values.get(variable) - return_value = api_value - - # If we have a 'drop threshold' specified, then check it and correct if needed - if ( - entity_description.previous_value_drop_threshold is not None - and previous_value is not None - and api_value is not None - ): - _LOGGER.debug( - ( - "%s - Drop threshold specified (%s), checking for drop... API" - " Value: %s, Previous Value: %s" - ), - entity_description.name, - entity_description.previous_value_drop_threshold, - api_value, - previous_value, - ) - diff = float(api_value) - float(previous_value) - - # Check if the value has dropped (negative value i.e. < 0) and it has only - # dropped by a small amount, if so, use the previous value. - # Note - The energy dashboard takes care of drops within 10% - # of the current value, however if the value is low e.g. 0.2 - # and drops by 0.1 it classes as a reset. - if -(entity_description.previous_value_drop_threshold) <= diff < 0: - _LOGGER.debug( - ( - "Diff is negative, but only by a small amount therefore not a" - " nightly reset, using previous value (%s) instead of api value" - " (%s)" - ), - previous_value, - api_value, - ) - return_value = previous_value - else: - _LOGGER.debug( - "%s - No drop detected, using API value", entity_description.name - ) - - # Lifetime total values should always be increasing, they will never reset, - # however the API sometimes returns 0 values when the clock turns to 00:00 - # local time in that scenario we should just return the previous value - # Scenarios: - # 1 - System has a genuine 0 value when it it first commissioned: - # - will return 0 until a non-zero value is registered - # 2 - System has been running fine but temporarily resets to 0 briefly - # at midnight: - # - will return the previous value - # 3 - HA is restarted during the midnight 'outage' - Not handled: - # - Previous value will not exist meaning 0 will be returned - # - This is an edge case that would be better handled by looking - # up the previous value of the entity from the recorder - if entity_description.never_resets and api_value == 0 and previous_value: - _LOGGER.debug( - ( - "API value is 0, but this value should never reset, returning" - " previous value (%s) instead" - ), - previous_value, - ) - return_value = previous_value - - self.previous_values[variable] = return_value - - return return_value From 7c83fd0bf94a5edf8ac9ca865e3eaab22d183ad9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 23 Jul 2025 15:05:39 +0200 Subject: [PATCH 060/235] Add twice_daily forecast to SMHI (#148882) --- homeassistant/components/smhi/coordinator.py | 5 + homeassistant/components/smhi/weather.py | 23 +- .../smhi/snapshots/test_weather.ambr | 292 +++++++++++++++++- tests/components/smhi/test_weather.py | 20 ++ 4 files changed, 333 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index ba7542694df..d8e85917db5 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -24,6 +24,7 @@ class SMHIForecastData: daily: list[SMHIForecast] hourly: list[SMHIForecast] + twice_daily: list[SMHIForecast] class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): @@ -52,6 +53,9 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): async with asyncio.timeout(TIMEOUT): _forecast_daily = await self._smhi_api.async_get_daily_forecast() _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + _forecast_twice_daily = ( + await self._smhi_api.async_get_twice_daily_forecast() + ) except SmhiForecastException as ex: raise UpdateFailed( "Failed to retrieve the forecast from the SMHI API" @@ -60,6 +64,7 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): return SMHIForecastData( daily=_forecast_daily, hourly=_forecast_hourly, + twice_daily=_forecast_twice_daily, ) @property diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index ccfff7cc2e5..9496321b8b4 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -26,6 +26,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -109,7 +110,9 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_native_pressure_unit = UnitOfPressure.HPA _attr_supported_features = ( - WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY ) _attr_name = None @@ -146,7 +149,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): super()._handle_coordinator_update() def _get_forecast_data( - self, forecast_data: list[SMHIForecast] | None + self, forecast_data: list[SMHIForecast] | None, forecast_type: str ) -> list[Forecast] | None: """Get forecast data.""" if forecast_data is None or len(forecast_data) < 3: @@ -161,7 +164,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): ): condition = ATTR_CONDITION_CLEAR_NIGHT - data.append( + new_forecast = Forecast( { ATTR_FORECAST_TIME: forecast["valid_time"].isoformat(), ATTR_FORECAST_NATIVE_TEMP: forecast["temperature_max"], @@ -179,13 +182,23 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): ATTR_FORECAST_CLOUD_COVERAGE: forecast["total_cloud"], } ) + if forecast_type == "twice_daily": + new_forecast[ATTR_FORECAST_IS_DAYTIME] = False + if forecast["valid_time"].hour == 12: + new_forecast[ATTR_FORECAST_IS_DAYTIME] = True + + data.append(new_forecast) return data def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self.coordinator.data.daily) + return self._get_forecast_data(self.coordinator.data.daily, "daily") def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self.coordinator.data.hourly) + return self._get_forecast_data(self.coordinator.data.hourly, "hourly") + + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Service to retrieve the twice daily forecast.""" + return self._get_forecast_data(self.coordinator.data.twice_daily, "twice_daily") diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index 083dcbd6404..2df5bb01a3c 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -68,7 +68,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -287,7 +287,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -299,3 +299,291 @@ 'wind_speed_unit': , }) # --- +# name: test_twice_daily_forecast_service[load_platforms0] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'fog', + 'datetime': '2023-08-07T08:00:00+00:00', + 'humidity': 100, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 992.4, + 'temperature': 18.4, + 'templow': 18.4, + 'wind_bearing': 93, + 'wind_gust_speed': 22.32, + 'wind_speed': 9.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00+00:00', + 'humidity': 96, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 991.7, + 'temperature': 18.4, + 'templow': 17.1, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-08T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 987.5, + 'temperature': 18.4, + 'templow': 14.8, + 'wind_bearing': 357, + 'wind_gust_speed': 10.44, + 'wind_speed': 3.96, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'humidity': 97, + 'is_daytime': True, + 'precipitation': 0.3, + 'pressure': 984.1, + 'temperature': 18.4, + 'templow': 12.8, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-09T00:00:00+00:00', + 'humidity': 85, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 995.6, + 'temperature': 18.4, + 'templow': 11.2, + 'wind_bearing': 193, + 'wind_gust_speed': 48.6, + 'wind_speed': 19.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'humidity': 95, + 'is_daytime': True, + 'precipitation': 1.1, + 'pressure': 1001.4, + 'temperature': 18.4, + 'templow': 11.1, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-10T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 3.6, + 'pressure': 1007.8, + 'temperature': 18.4, + 'templow': 10.4, + 'wind_bearing': 200, + 'wind_gust_speed': 28.08, + 'wind_speed': 14.4, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00+00:00', + 'humidity': 75, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1011.1, + 'temperature': 18.4, + 'templow': 13.9, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T00:00:00+00:00', + 'humidity': 98, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1012.3, + 'temperature': 18.4, + 'templow': 11.7, + 'wind_bearing': 169, + 'wind_gust_speed': 16.56, + 'wind_speed': 7.56, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00+00:00', + 'humidity': 69, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 18.4, + 'templow': 17.6, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2023-08-12T00:00:00+00:00', + 'humidity': 97, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.8, + 'temperature': 18.4, + 'templow': 12.3, + 'wind_bearing': 191, + 'wind_gust_speed': 18.0, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00+00:00', + 'humidity': 82, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 18.4, + 'templow': 17.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 12, + 'condition': 'clear-night', + 'datetime': '2023-08-13T00:00:00+00:00', + 'humidity': 92, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1013.9, + 'temperature': 18.4, + 'templow': 13.6, + 'wind_bearing': 233, + 'wind_gust_speed': 20.16, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00+00:00', + 'humidity': 59, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1013.6, + 'temperature': 20.0, + 'templow': 18.4, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T00:00:00+00:00', + 'humidity': 91, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.2, + 'temperature': 18.4, + 'templow': 13.5, + 'wind_bearing': 227, + 'wind_gust_speed': 23.4, + 'wind_speed': 10.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00+00:00', + 'humidity': 56, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 20.8, + 'templow': 18.4, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-15T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 14.3, + 'wind_bearing': 196, + 'wind_gust_speed': 22.32, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00+00:00', + 'humidity': 64, + 'is_daytime': True, + 'precipitation': 2.4, + 'pressure': 1014.3, + 'temperature': 20.4, + 'templow': 18.4, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 38, + 'condition': 'clear-night', + 'datetime': '2023-08-16T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 13.8, + 'wind_bearing': 228, + 'wind_gust_speed': 21.24, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00+00:00', + 'humidity': 61, + 'is_daytime': True, + 'precipitation': 1.2, + 'pressure': 1014.0, + 'temperature': 20.2, + 'templow': 18.4, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 5cf8c2ae41d..9acacb10ffa 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -473,3 +473,23 @@ async def test_forecast_service( return_response=True, ) assert response == snapshot + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.WEATHER]], +) +async def test_twice_daily_forecast_service( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast service.""" + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {"entity_id": ENTITY_ID, "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == snapshot From 9a9f65dc366a4407dd92dfaeac7c6f18b2218c04 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:06:25 +0200 Subject: [PATCH 061/235] Improve config flow tests in Onkyo (#149199) --- .../components/onkyo/quality_scale.yaml | 11 +- tests/components/onkyo/conftest.py | 7 +- tests/components/onkyo/test_config_flow.py | 569 ++++++++---------- 3 files changed, 271 insertions(+), 316 deletions(-) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 1e8bf07e66a..758055a974c 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: - status: todo - comment: | - Coverage is 100%, but the tests need to be improved. + config-flow-test-coverage: done dependency-transparency: done docs-actions: done docs-high-level-description: done @@ -39,9 +36,9 @@ rules: parallel-updates: todo reauthentication-flow: status: exempt - comment: | - This integration does not require authentication. - test-coverage: todo + comment: This integration does not require authentication. + test-coverage: done + # Gold devices: todo diagnostics: todo diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index c6459a2b1f2..6528168f723 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -8,9 +8,8 @@ from aioonkyo import Code, Instruction, Kind, Receiver, Status, Zone, status import pytest from homeassistant.components.onkyo.const import DOMAIN -from homeassistant.const import CONF_HOST -from . import RECEIVER_INFO, mock_discovery +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery from tests.common import MockConfigEntry @@ -24,7 +23,7 @@ def mock_default_discovery() -> Generator[None]: DEVICE_INTERVIEW_TIMEOUT=1, DEVICE_DISCOVERY_TIMEOUT=1, ), - mock_discovery([RECEIVER_INFO]), + mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]), ): yield @@ -164,7 +163,7 @@ def mock_receiver( @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" - data = {CONF_HOST: RECEIVER_INFO.host} + data = {"host": RECEIVER_INFO.host} options = { "volume_resolution": 80, "max_volume": 100, diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index df10e266982..b56ab4b7028 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,5 +1,7 @@ """Test Onkyo config flow.""" +from contextlib import AbstractContextManager, nullcontext + from aioonkyo import ReceiverInfo import pytest @@ -15,7 +17,7 @@ from homeassistant.components.onkyo.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, SsdpServiceInfo, @@ -26,186 +28,87 @@ from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery, setup_integration from tests.common import MockConfigEntry -def _entry_title(receiver_info: ReceiverInfo) -> str: +def _receiver_display_name(receiver_info: ReceiverInfo) -> str: return f"{receiver_info.model_name} ({receiver_info.host})" -async def test_user_initial_menu(hass: HomeAssistant) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert init_result["type"] is FlowResultType.MENU - # Check if the values are there, but ignore order - assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} - - -async def test_manual_valid_host(hass: HomeAssistant) -> None: - """Test valid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: RECEIVER_INFO.host}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == _entry_title( - RECEIVER_INFO - ) - - -async def test_manual_invalid_host(hass: HomeAssistant) -> None: - """Test invalid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - with mock_discovery([]): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "cannot_connect" - - -async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: - """Test valid host entered.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - with mock_discovery(None): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "unknown" - - -async def test_eiscp_discovery_no_devices_found(hass: HomeAssistant) -> None: - """Test eiscp discovery with no devices found.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - with mock_discovery([]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_eiscp_discovery_unexpected_exception(hass: HomeAssistant) -> None: - """Test eiscp discovery with an unexpected exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - with mock_discovery(None): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - @pytest.mark.usefixtures("mock_setup_entry") -async def test_eiscp_discovery( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test eiscp discovery.""" - mock_config_entry.add_to_hass(hass) - +async def test_manual(hass: HomeAssistant) -> None: + """Test successful manual.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - with mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert result["type"] is FlowResultType.FORM - - assert result["data_schema"] is not None - schema = result["data_schema"].schema - container = schema["device"].container - assert container == {RECEIVER_INFO_2.identifier: _entry_title(RECEIVER_INFO_2)} + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"device": RECEIVER_INFO_2.identifier}, + result["flow_id"], {"next_step_id": "manual"} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "volume_resolution": 200, - "input_sources": ["TV"], - "listening_modes": ["THX"], + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["host"] == RECEIVER_INFO_2.host + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "cannot_connect"), + (mock_discovery([RECEIVER_INFO]), "cannot_connect"), + ], +) @pytest.mark.usefixtures("mock_setup_entry") -async def test_ssdp_discovery_success(hass: HomeAssistant) -> None: - """Test SSDP discovery with valid host.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.0.101:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", +async def test_manual_recoverable_error( + hass: HomeAssistant, mock_discovery: AbstractContextManager, error_reason: str +) -> None: + """Test manual with a recoverable error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error_reason} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} ) assert result["type"] is FlowResultType.FORM @@ -214,160 +117,234 @@ async def test_ssdp_discovery_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "volume_resolution": 200, - "input_sources": ["TV"], - "listening_modes": ["THX"], + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["host"] == RECEIVER_INFO.host - assert result["result"].unique_id == RECEIVER_INFO.identifier + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name -async def test_ssdp_discovery_already_configured( +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test SSDP discovery with already configured device.""" - mock_config_entry.add_to_hass(hass) - - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.0.101:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", - ) + """Test manual with an error.""" + await setup_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO.host} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: - """Test SSDP discovery with host info error.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful eiscp discovery.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - with mock_discovery(None): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "eiscp_discovery" + + devices = result["data_schema"].schema["device"].container + assert devices == { + RECEIVER_INFO_2.identifier: _receiver_display_name(RECEIVER_INFO_2) + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device": RECEIVER_INFO_2.identifier} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name + + +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "no_devices_found"), + (mock_discovery([RECEIVER_INFO]), "no_devices_found"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test eiscp discovery with an error.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert result["reason"] == error_reason -async def test_ssdp_discovery_host_none_info(hass: HomeAssistant) -> None: - """Test SSDP discovery with host info error.""" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful SSDP discovery.""" + await setup_integration(hass, mock_config_entry) + discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", - ) - - with mock_discovery([]): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_ssdp_discovery_no_location(hass: HomeAssistant) -> None: - """Test SSDP discovery with no location.""" - discovery_info = SsdpServiceInfo( - ssdp_location=None, + ssdp_location=f"http://{RECEIVER_INFO_2.host}:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name -async def test_ssdp_discovery_no_host(hass: HomeAssistant) -> None: - """Test SSDP discovery with no host.""" +@pytest.mark.parametrize( + ("ssdp_location", "mock_discovery", "error_reason"), + [ + (None, nullcontext(), "unknown"), + ("http://", nullcontext(), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery(None), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery([]), "cannot_connect"), + ( + f"http://{RECEIVER_INFO_2.host}:8080", + mock_discovery([RECEIVER_INFO]), + "cannot_connect", + ), + (f"http://{RECEIVER_INFO.host}:8080", nullcontext(), "already_configured"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + ssdp_location: str | None, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test SSDP discovery with an error.""" + await setup_integration(hass, mock_config_entry) + discovery_info = SsdpServiceInfo( - ssdp_location="http://", + ssdp_location=ssdp_location, upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) + with mock_discovery: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info + ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_configure_no_resolution(hass: HomeAssistant) -> None: - """Test receiver configure with no resolution set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"input_sources": ["TV"]}, - ) + assert result["reason"] == error_reason @pytest.mark.usefixtures("mock_setup_entry") async def test_configure(hass: HomeAssistant) -> None: """Test receiver configure.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "manual"}, - ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_HOST: RECEIVER_INFO.host}, + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + assert result["description_placeholders"]["name"] == _receiver_display_name( + RECEIVER_INFO ) result = await hass.config_entries.flow.async_configure( @@ -378,6 +355,8 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} @@ -389,6 +368,8 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: [], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} @@ -400,6 +381,7 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["options"] == { OPTION_VOLUME_RESOLUTION: 200, @@ -409,36 +391,11 @@ async def test_configure(hass: HomeAssistant) -> None: } -async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: - """Test receiver configure with invalid resolution.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 42, "input_sources": ["TV"]}, - ) - - @pytest.mark.usefixtures("mock_setup_entry") async def test_reconfigure( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test the reconfigure config flow.""" + """Test successful reconfigure flow.""" await setup_integration(hass, mock_config_entry) old_host = mock_config_entry.data[CONF_HOST] @@ -449,21 +406,19 @@ async def test_reconfigure( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": mock_config_entry.data[CONF_HOST]} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "configure_receiver" - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={OPTION_VOLUME_RESOLUTION: 200}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: mock_config_entry.data[CONF_HOST]} ) - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={OPTION_VOLUME_RESOLUTION: 200} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data[CONF_HOST] == old_host assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 @@ -474,24 +429,25 @@ async def test_reconfigure( @pytest.mark.usefixtures("mock_setup_entry") -async def test_reconfigure_new_device( +async def test_reconfigure_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test the reconfigure config flow with new device.""" + """Test reconfigure flow with an error.""" await setup_integration(hass, mock_config_entry) old_unique_id = mock_config_entry.unique_id result = await mock_config_entry.start_reconfigure_flow(hass) - with mock_discovery([RECEIVER_INFO_2]): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "unique_id_mismatch" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" # unique id should remain unchanged assert mock_config_entry.unique_id == old_unique_id @@ -519,6 +475,9 @@ async def test_options_flow( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ From 4730c5b8316bb2e7cb1dbfabf9cb70a9d886aa15 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:07:37 +0200 Subject: [PATCH 062/235] Add logging to Tuya for devices that cannot be supported (#149192) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/__init__.py | 11 +++++ tests/components/tuya/__init__.py | 4 ++ .../fixtures/ydkt_dolceclima_unsupported.json | 23 +++++++++ .../components/tuya/snapshots/test_init.ambr | 36 ++++++++++++++ tests/components/tuya/test_init.py | 49 +++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json create mode 100644 tests/components/tuya/snapshots/test_init.ambr create mode 100644 tests/components/tuya/test_init.py diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 106075e9314..6c3aa146158 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -153,6 +153,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool # Register known device IDs device_registry = dr.async_get(hass) for device in manager.device_map.values(): + if not device.status and not device.status_range and not device.function: + # If the device has no status, status_range or function, + # it cannot be supported + LOGGER.info( + "Device %s (%s) has been ignored as it does not provide any" + " standard instructions (status, status_range and function are" + " all empty) - see %s", + device.product_name, + device.id, + "https://github.com/tuya/tuya-device-sharing-sdk/issues/11", + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index d9016d18def..632d05ce931 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -148,6 +148,10 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "ydkt_dolceclima_unsupported": [ + # https://github.com/orgs/home-assistant/discussions/288 + # unsupported device - no platforms + ], "wk_wifi_smart_gas_boiler_thermostat": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, diff --git a/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json b/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json new file mode 100644 index 00000000000..f50aab00a26 --- /dev/null +++ b/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "DOLCECLIMA 10 HP WIFI", + "category": "ydkt", + "product_id": "jevroj5aguwdbs2e", + "product_name": "DOLCECLIMA 10 HP WIFI", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-09T18:39:25+00:00", + "create_time": "2025-07-09T18:39:25+00:00", + "update_time": "2025-07-09T18:39:25+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr new file mode 100644 index 00000000000..084e9a84401 --- /dev/null +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -0,0 +1,36 @@ +# serializer version: 1 +# name: test_unsupported_device[ydkt_dolceclima_unsupported] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'mock_device_id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'DOLCECLIMA 10 HP WIFI (unsupported)', + 'model_id': 'jevroj5aguwdbs2e', + 'name': 'DOLCECLIMA 10 HP WIFI', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py new file mode 100644 index 00000000000..8fbf6fb4e3b --- /dev/null +++ b/tests/components/tuya/test_init.py @@ -0,0 +1,49 @@ +"""Test Tuya initialization.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("mock_device_code", ["ydkt_dolceclima_unsupported"]) +async def test_unsupported_device( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported device.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + # Device is registered + assert ( + dr.async_entries_for_config_entry(device_registry, mock_config_entry.entry_id) + == snapshot + ) + # No entities registered + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + # Information log entry added + assert ( + "Device DOLCECLIMA 10 HP WIFI (mock_device_id) has been ignored" + " as it does not provide any standard instructions (status, status_range" + " and function are all empty) - see " + "https://github.com/tuya/tuya-device-sharing-sdk/issues/11" in caplog.text + ) From 6d3872252b816a08a4581ed8129fb28b8ddc64ce Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 15:09:17 +0200 Subject: [PATCH 063/235] Fix one inconsistent spelling of "AppArmor" in `hassio` (#149310) --- homeassistant/components/hassio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index e34aa020c5a..6d67b4b79c0 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -238,7 +238,7 @@ "name": "OS Agent version" }, "apparmor_version": { - "name": "Apparmor version" + "name": "AppArmor version" }, "cpu_percent": { "name": "CPU percent" From 1c8ae8a21b5bd5df441b880a7e73568ccb609ad4 Mon Sep 17 00:00:00 2001 From: Nick Kuiper <65495045+NickKoepr@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:12:53 +0200 Subject: [PATCH 064/235] Add switches for blue current integration. (#146210) --- .../components/blue_current/__init__.py | 47 +++-- .../components/blue_current/const.py | 11 ++ .../components/blue_current/icons.json | 11 ++ .../components/blue_current/strings.json | 11 ++ .../components/blue_current/switch.py | 169 ++++++++++++++++++ tests/components/blue_current/__init__.py | 19 ++ tests/components/blue_current/test_sensor.py | 13 +- tests/components/blue_current/test_switch.py | 152 ++++++++++++++++ 8 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/blue_current/switch.py create mode 100644 tests/components/blue_current/test_switch.py diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 775ca16a12a..eeda91a70a3 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -15,23 +15,31 @@ from bluecurrent_api.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE +from .const import ( + CHARGEPOINT_SETTINGS, + CHARGEPOINT_STATUS, + DOMAIN, + EVSE_ID, + LOGGER, + PLUG_AND_CHARGE, + VALUE, +) type BlueCurrentConfigEntry = ConfigEntry[Connector] -PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" DATA = "data" DELAY = 5 GRID = "GRID" OBJECT = "object" -VALUE_TYPES = ["CH_STATUS"] +VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] async def async_setup_entry( @@ -94,7 +102,7 @@ class Connector: elif object_name in VALUE_TYPES: value_data: dict = message[DATA] evse_id = value_data.pop(EVSE_ID) - self.update_charge_point(evse_id, value_data) + self.update_charge_point(evse_id, object_name, value_data) # gets grid key / values elif GRID in object_name: @@ -106,26 +114,37 @@ class Connector: """Handle incoming chargepoint data.""" await asyncio.gather( *( - self.handle_charge_point( - entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME] - ) + self.handle_charge_point(entry[EVSE_ID], entry) for entry in charge_points_data ), self.client.get_grid_status(charge_points_data[0][EVSE_ID]), ) - async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None: + async def handle_charge_point( + self, evse_id: str, charge_point: dict[str, Any] + ) -> None: """Add the chargepoint and request their data.""" - self.add_charge_point(evse_id, model, name) + self.add_charge_point(evse_id, charge_point) await self.client.get_status(evse_id) - def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + def add_charge_point(self, evse_id: str, charge_point: dict[str, Any]) -> None: """Add a charge point to charge_points.""" - self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + self.charge_points[evse_id] = charge_point - def update_charge_point(self, evse_id: str, data: dict) -> None: + def update_charge_point(self, evse_id: str, update_type: str, data: dict) -> None: """Update the charge point data.""" - self.charge_points[evse_id].update(data) + charge_point = self.charge_points[evse_id] + if update_type == CHARGEPOINT_SETTINGS: + # Update the plug and charge object. The library parses this object to a bool instead of an object. + plug_and_charge = charge_point.get(PLUG_AND_CHARGE) + if plug_and_charge is not None: + plug_and_charge[VALUE] = data[PLUG_AND_CHARGE] + + # Remove the plug and charge object from the data list before updating. + del data[PLUG_AND_CHARGE] + + charge_point.update(data) + self.dispatch_charge_point_update_signal(evse_id) def dispatch_charge_point_update_signal(self, evse_id: str) -> None: diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 008e6efa872..33e0e8b1176 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,3 +8,14 @@ LOGGER = logging.getLogger(__package__) EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +PLUG_AND_CHARGE = "plug_and_charge" +VALUE = "value" +PERMISSION = "permission" +CHARGEPOINT_STATUS = "CH_STATUS" +CHARGEPOINT_SETTINGS = "CH_SETTINGS" +BLOCK = "block" +UNAVAILABLE = "unavailable" +AVAILABLE = "available" +LINKED_CHARGE_CARDS = "linked_charge_cards_only" +PUBLIC_CHARGING = "public_charging" +ACTIVITY = "activity" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index ce936902e91..28d4acbc1d8 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -30,6 +30,17 @@ "stop_charge_session": { "default": "mdi:stop" } + }, + "switch": { + "plug_and_charge": { + "default": "mdi:ev-plug-type2" + }, + "linked_charge_cards": { + "default": "mdi:account-group" + }, + "block": { + "default": "mdi:lock" + } } } } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 28eb20fa912..0a99af603cc 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -124,6 +124,17 @@ "reset": { "name": "Reset" } + }, + "switch": { + "plug_and_charge": { + "name": "Plug & Charge" + }, + "linked_charge_cards_only": { + "name": "Linked charging cards only" + }, + "block": { + "name": "Block charge point" + } } } } diff --git a/homeassistant/components/blue_current/switch.py b/homeassistant/components/blue_current/switch.py new file mode 100644 index 00000000000..a0848387901 --- /dev/null +++ b/homeassistant/components/blue_current/switch.py @@ -0,0 +1,169 @@ +"""Support for Blue Current switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PLUG_AND_CHARGE, BlueCurrentConfigEntry, Connector +from .const import ( + AVAILABLE, + BLOCK, + LINKED_CHARGE_CARDS, + PUBLIC_CHARGING, + UNAVAILABLE, + VALUE, +) +from .entity import ChargepointEntity + + +@dataclass(kw_only=True, frozen=True) +class BlueCurrentSwitchEntityDescription(SwitchEntityDescription): + """Describes a Blue Current switch entity.""" + + function: Callable[[Connector, str, bool], Any] + + turn_on_off_fn: Callable[[str, Connector], tuple[bool, bool]] + """Update the switch based on the latest data received from the websocket. The first returned boolean is _attr_is_on, the second one has_value.""" + + +def update_on_value_and_activity( + key: str, evse_id: str, connector: Connector, reverse_is_on: bool = False +) -> tuple[bool, bool]: + """Return the updated state of the switch based on received chargepoint data and activity.""" + + data_object = connector.charge_points[evse_id].get(key) + is_on = data_object[VALUE] if data_object is not None else None + activity = connector.charge_points[evse_id].get("activity") + + if is_on is not None and activity == AVAILABLE: + return is_on if not reverse_is_on else not is_on, True + return False, False + + +def update_block_switch(evse_id: str, connector: Connector) -> tuple[bool, bool]: + """Return the updated data for a block switch.""" + activity = connector.charge_points[evse_id].get("activity") + return activity == UNAVAILABLE, activity in [AVAILABLE, UNAVAILABLE] + + +def update_charge_point( + key: str, evse_id: str, connector: Connector, new_switch_value: bool +) -> None: + """Change charge point data when the state of the switch changes.""" + data_objects = connector.charge_points[evse_id].get(key) + if data_objects is not None: + data_objects[VALUE] = new_switch_value + + +async def set_plug_and_charge(connector: Connector, evse_id: str, value: bool) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_plug_and_charge(evse_id, value) + update_charge_point(PLUG_AND_CHARGE, evse_id, connector, value) + + +async def set_linked_charge_cards( + connector: Connector, evse_id: str, value: bool +) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_linked_charge_cards_only(evse_id, value) + update_charge_point(PUBLIC_CHARGING, evse_id, connector, not value) + + +SWITCHES = ( + BlueCurrentSwitchEntityDescription( + key=PLUG_AND_CHARGE, + translation_key=PLUG_AND_CHARGE, + function=set_plug_and_charge, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity(PLUG_AND_CHARGE, evse_id, connector) + ), + ), + BlueCurrentSwitchEntityDescription( + key=LINKED_CHARGE_CARDS, + translation_key=LINKED_CHARGE_CARDS, + function=set_linked_charge_cards, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity( + PUBLIC_CHARGING, evse_id, connector, reverse_is_on=True + ) + ), + ), + BlueCurrentSwitchEntityDescription( + key=BLOCK, + translation_key=BLOCK, + function=lambda connector, evse_id, value: connector.client.block( + evse_id, value + ), + turn_on_off_fn=update_block_switch, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BlueCurrentConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Blue Current switches.""" + connector = entry.runtime_data + + async_add_entities( + ChargePointSwitch( + connector, + evse_id, + switch, + ) + for evse_id in connector.charge_points + for switch in SWITCHES + ) + + +class ChargePointSwitch(ChargepointEntity, SwitchEntity): + """Base charge point switch.""" + + has_value = True + entity_description: BlueCurrentSwitchEntityDescription + + def __init__( + self, + connector: Connector, + evse_id: str, + switch: BlueCurrentSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(connector, evse_id) + + self.key = switch.key + self.entity_description = switch + self.evse_id = evse_id + self._attr_available = True + self._attr_unique_id = f"{switch.key}_{evse_id}" + + async def call_function(self, value: bool) -> None: + """Call the function to set setting.""" + await self.entity_description.function(self.connector, self.evse_id, value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(True) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(False) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the switch.""" + new_state = self.entity_description.turn_on_off_fn(self.evse_id, self.connector) + self._attr_is_on = new_state[0] + self.has_value = new_state[1] diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 97acff39a62..402d644747a 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -4,18 +4,28 @@ from __future__ import annotations from asyncio import Event, Future from dataclasses import dataclass +from typing import Any from unittest.mock import MagicMock, patch from bluecurrent_api import Client +from homeassistant.components.blue_current import EVSE_ID, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import PUBLIC_CHARGING from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +DEFAULT_CHARGE_POINT_OPTIONS = { + PLUG_AND_CHARGE: {"value": False, "permission": "write"}, + PUBLIC_CHARGING: {"value": True, "permission": "write"}, +} + DEFAULT_CHARGE_POINT = { "evse_id": "101", "model_type": "", "name": "", + "activity": "available", + **DEFAULT_CHARGE_POINT_OPTIONS, } @@ -77,11 +87,20 @@ def create_client_mock( """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def update_charge_point( + evse_id: str, event_object: str, settings: dict[str, Any] + ) -> None: + """Update the charge point data by sending an event.""" + await client_mock.receiver( + {"object": event_object, "data": {EVSE_ID: evse_id, **settings}} + ) + client_mock.connect.side_effect = connect client_mock.wait_for_charge_points.side_effect = wait_for_charge_points client_mock.get_charge_points.side_effect = get_charge_points client_mock.get_status.side_effect = get_status client_mock.get_grid_status.side_effect = get_grid_status + client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py index cf20b7334b4..773ffbccd97 100644 --- a/tests/components/blue_current/test_sensor.py +++ b/tests/components/blue_current/test_sensor.py @@ -7,17 +7,10 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import DEFAULT_CHARGE_POINT, init_integration from tests.common import MockConfigEntry -charge_point = { - "evse_id": "101", - "model_type": "", - "name": "", -} - - charge_point_status = { "actual_v1": 14, "actual_v2": 18, @@ -97,7 +90,7 @@ async def test_sensors_created( hass, config_entry, "sensor", - charge_point, + DEFAULT_CHARGE_POINT, charge_point_status | charge_point_status_timestamps, grid, ) @@ -116,7 +109,7 @@ async def test_sensors( ) -> None: """Test the underlying sensors.""" await init_integration( - hass, config_entry, "sensor", charge_point, charge_point_status, grid + hass, config_entry, "sensor", DEFAULT_CHARGE_POINT, charge_point_status, grid ) for entity_id, key in charge_point_entity_ids.items(): diff --git a/tests/components/blue_current/test_switch.py b/tests/components/blue_current/test_switch.py new file mode 100644 index 00000000000..c7837816d75 --- /dev/null +++ b/tests/components/blue_current/test_switch.py @@ -0,0 +1,152 @@ +"""The tests for Bluecurrent switches.""" + +from homeassistant.components.blue_current import CHARGEPOINT_SETTINGS, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import ( + ACTIVITY, + CHARGEPOINT_STATUS, + PUBLIC_CHARGING, + UNAVAILABLE, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import DEFAULT_CHARGE_POINT, init_integration + +from tests.common import MockConfigEntry + + +async def test_switches( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the underlying switches.""" + + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.unique_id == switch.unique_id + + +async def test_switches_offline( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if switches are disabled when needed.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "offline" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == UNAVAILABLE + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.entity_id == switch.entity_id + + +async def test_block_switch_availability( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the block switch is unavailable when charging.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "charging" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + state = hass.states.get("switch.101_block_charge_point") + assert state and state.state == UNAVAILABLE + + +async def test_toggle( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the on / off methods and if the switch gets updated.""" + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_ON + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_OFF + + +async def test_setting_change( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the state of the switches are updated when an update message from the websocket comes in.""" + integration = await init_integration(hass, config_entry, Platform.SWITCH) + client_mock = integration[0] + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + assert state.state == STATE_OFF + + await client_mock.update_charge_point( + "101", + CHARGEPOINT_SETTINGS, + { + PLUG_AND_CHARGE: True, + PUBLIC_CHARGING: {"value": False, "permission": "write"}, + }, + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_block_charge_point") + assert plug_and_charge_switch.state == STATE_OFF + + await client_mock.update_charge_point( + "101", CHARGEPOINT_STATUS, {ACTIVITY: UNAVAILABLE} + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_UNAVAILABLE + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_UNAVAILABLE + + switch = hass.states.get("switch.101_block_charge_point") + assert switch.state == STATE_ON From d9b25770ad3dc047e17b873d0c1370e4956ca5ee Mon Sep 17 00:00:00 2001 From: Imeon-Energy Date: Wed, 23 Jul 2025 15:32:32 +0200 Subject: [PATCH 065/235] Remove sensors from Imeon Inverter (#148542) Co-authored-by: TheBushBoy Co-authored-by: Joost Lekkerkerker --- .../components/imeon_inverter/coordinator.py | 7 +- .../components/imeon_inverter/icons.json | 27 - .../components/imeon_inverter/manifest.json | 2 +- .../components/imeon_inverter/sensor.py | 69 --- .../components/imeon_inverter/strings.json | 27 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../imeon_inverter/snapshots/test_sensor.ambr | 503 ------------------ 8 files changed, 5 insertions(+), 634 deletions(-) diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py index 8342240b9ff..f1963a45579 100644 --- a/homeassistant/components/imeon_inverter/coordinator.py +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -88,10 +88,7 @@ class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): # Store data for key, val in self._api.storage.items(): - if key == "timeline": - data[key] = val - else: - for sub_key, sub_val in val.items(): - data[f"{key}_{sub_key}"] = sub_val + for sub_key, sub_val in val.items(): + data[f"{key}_{sub_key}"] = sub_val return data diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json index 1c74cf4c745..6ede2416afa 100644 --- a/homeassistant/components/imeon_inverter/icons.json +++ b/homeassistant/components/imeon_inverter/icons.json @@ -1,12 +1,6 @@ { "entity": { "sensor": { - "battery_autonomy": { - "default": "mdi:battery-clock" - }, - "battery_charge_time": { - "default": "mdi:battery-charging" - }, "battery_power": { "default": "mdi:battery" }, @@ -58,9 +52,6 @@ "meter_power": { "default": "mdi:power-plug" }, - "meter_power_protocol": { - "default": "mdi:protocol" - }, "output_current_l1": { "default": "mdi:current-ac" }, @@ -115,30 +106,12 @@ "temp_component_temperature": { "default": "mdi:thermometer" }, - "monitoring_building_consumption": { - "default": "mdi:home-lightning-bolt" - }, - "monitoring_economy_factor": { - "default": "mdi:chart-bar" - }, - "monitoring_grid_consumption": { - "default": "mdi:transmission-tower" - }, - "monitoring_grid_injection": { - "default": "mdi:transmission-tower-export" - }, - "monitoring_grid_power_flow": { - "default": "mdi:power-plug" - }, "monitoring_self_consumption": { "default": "mdi:percent" }, "monitoring_self_sufficiency": { "default": "mdi:percent" }, - "monitoring_solar_production": { - "default": "mdi:solar-power" - }, "monitoring_minute_building_consumption": { "default": "mdi:home-lightning-bolt" }, diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json index 1398521dc45..a9a37f3fd9c 100644 --- a/homeassistant/components/imeon_inverter/manifest.json +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["imeon_inverter_api==0.3.12"], + "requirements": ["imeon_inverter_api==0.3.14"], "ssdp": [ { "manufacturer": "IMEON", diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py index e1d05d0ecf6..32d40923fa1 100644 --- a/homeassistant/components/imeon_inverter/sensor.py +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -18,7 +18,6 @@ from homeassistant.const import ( UnitOfFrequency, UnitOfPower, UnitOfTemperature, - UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -34,20 +33,6 @@ _LOGGER = logging.getLogger(__name__) SENSOR_DESCRIPTIONS = ( # Battery - SensorEntityDescription( - key="battery_autonomy", - translation_key="battery_autonomy", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="battery_charge_time", - translation_key="battery_charge_time", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - ), SensorEntityDescription( key="battery_power", translation_key="battery_power", @@ -171,13 +156,6 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( - key="meter_power_protocol", - translation_key="meter_power_protocol", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), # AC Output SensorEntityDescription( key="output_current_l1", @@ -308,45 +286,6 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, ), # Monitoring (data over the last 24 hours) - SensorEntityDescription( - key="monitoring_building_consumption", - translation_key="monitoring_building_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_economy_factor", - translation_key="monitoring_economy_factor", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_consumption", - translation_key="monitoring_grid_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_injection", - translation_key="monitoring_grid_injection", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_power_flow", - translation_key="monitoring_grid_power_flow", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), SensorEntityDescription( key="monitoring_self_consumption", translation_key="monitoring_self_consumption", @@ -361,14 +300,6 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ), - SensorEntityDescription( - key="monitoring_solar_production", - translation_key="monitoring_solar_production", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), # Monitoring (instant minute data) SensorEntityDescription( key="monitoring_minute_building_consumption", diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json index 218e1c4e4aa..86855361b8f 100644 --- a/homeassistant/components/imeon_inverter/strings.json +++ b/homeassistant/components/imeon_inverter/strings.json @@ -29,12 +29,6 @@ }, "entity": { "sensor": { - "battery_autonomy": { - "name": "Battery autonomy" - }, - "battery_charge_time": { - "name": "Battery charge time" - }, "battery_power": { "name": "Battery power" }, @@ -86,9 +80,6 @@ "meter_power": { "name": "Meter power" }, - "meter_power_protocol": { - "name": "Meter power protocol" - }, "output_current_l1": { "name": "Output current L1" }, @@ -143,30 +134,12 @@ "temp_component_temperature": { "name": "Component temperature" }, - "monitoring_building_consumption": { - "name": "Monitoring building consumption" - }, - "monitoring_economy_factor": { - "name": "Monitoring economy factor" - }, - "monitoring_grid_consumption": { - "name": "Monitoring grid consumption" - }, - "monitoring_grid_injection": { - "name": "Monitoring grid injection" - }, - "monitoring_grid_power_flow": { - "name": "Monitoring grid power flow" - }, "monitoring_self_consumption": { "name": "Monitoring self-consumption" }, "monitoring_self_sufficiency": { "name": "Monitoring self-sufficiency" }, - "monitoring_solar_production": { - "name": "Monitoring solar production" - }, "monitoring_minute_building_consumption": { "name": "Monitoring building consumption (minute)" }, diff --git a/requirements_all.txt b/requirements_all.txt index 479e5598aaf..4837f6e88b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ igloohome-api==0.1.1 ihcsdk==2.8.5 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib imgw_pib==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eae65039b8f..f928f1e2054 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ ifaddr==0.2.0 igloohome-api==0.1.1 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib imgw_pib==1.4.2 diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index 8816889f049..fb59aa9dede 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -55,118 +55,6 @@ 'state': '25.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_battery_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery autonomy', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': '111111111111111_battery_autonomy', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.5', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_battery_charge_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery charge time', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_charge_time', - 'unique_id': '111111111111111_battery_charge_time', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery charge time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_charge_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '120', - }) -# --- # name: test_sensors[sensor.imeon_inverter_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1172,118 +1060,6 @@ 'state': '2000.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Meter power protocol', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'meter_power_protocol', - 'unique_id': '111111111111111_meter_power_protocol', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Meter power protocol', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2018.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring building consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_building_consumption', - 'unique_id': '111111111111111_monitoring_building_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring building consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3000.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1340,117 +1116,6 @@ 'state': '50.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Monitoring economy factor', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_economy_factor', - 'unique_id': '111111111111111_monitoring_economy_factor', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Imeon inverter Monitoring economy factor', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.8', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_consumption', - 'unique_id': '111111111111111_monitoring_grid_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '500.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1507,62 +1172,6 @@ 'state': '8.3', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid injection', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_injection', - 'unique_id': '111111111111111_monitoring_grid_injection', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid injection', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '700.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1619,62 +1228,6 @@ 'state': '11.7', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid power flow', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_power_flow', - 'unique_id': '111111111111111_monitoring_grid_power_flow', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid power flow', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-200.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1841,62 +1394,6 @@ 'state': '90.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring solar production', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_solar_production', - 'unique_id': '111111111111111_monitoring_solar_production', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring solar production', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2600.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 22fa86398443e2108fa9760c548a48b64dd5c7df Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 23 Jul 2025 15:33:52 +0200 Subject: [PATCH 066/235] Discover ZWA-2 LED as a configuration entity in Z-Wave (#149298) --- .../components/zwave_js/discovery.py | 29 +++ homeassistant/components/zwave_js/light.py | 5 +- tests/components/zwave_js/conftest.py | 38 +++ .../fixtures/nabu_casa_zwa2_legacy_state.json | 231 ++++++++++++++++++ .../fixtures/nabu_casa_zwa2_state.json | 146 +++++++++++ tests/components/zwave_js/test_discovery.py | 48 ++++ 6 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json create mode 100644 tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 74ffedbc53f..761c80bb0bb 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -772,6 +772,35 @@ DISCOVERY_SCHEMAS = [ }, ), ), + # ZWA-2, discover LED control as configuration, default disabled + ## Production firmware (1.0) -> Color Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="color_onoff", + primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + absent_values=[ + SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), + ## Day-1 firmware update (1.1) -> Binary Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="onoff", + primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + absent_values=[ + COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 23ec240e5a7..a90515cd040 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -183,7 +183,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self._supports_color_temp: self._supported_color_modes.add(ColorMode.COLOR_TEMP) if not self._supported_color_modes: - self._supported_color_modes.add(ColorMode.BRIGHTNESS) + if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY: + self._supported_color_modes.add(ColorMode.ONOFF) + else: + self._supported_color_modes.add(ColorMode.BRIGHTNESS) self._calculate_color_values() # Entity class attributes diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1163da4971c..3c07869d5b7 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -538,6 +538,24 @@ def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="nabu_casa_zwa2_state") +def nabu_casa_zwa2_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2.""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_state.json", DOMAIN), + ) + + +@pytest.fixture(name="nabu_casa_zwa2_legacy_state") +def nabu_casa_zwa2_legacy_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2 (legacy firmware).""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_legacy_state.json", DOMAIN), + ) + + # model fixtures @@ -1358,3 +1376,23 @@ def zcombo_smoke_co_alarm_fixture( node = Node(client, zcombo_smoke_co_alarm_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="nabu_casa_zwa2") +def nabu_casa_zwa2_fixture( + client: MagicMock, nabu_casa_zwa2_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2.""" + node = Node(client, nabu_casa_zwa2_state) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="nabu_casa_zwa2_legacy") +def nabu_casa_zwa2_legacy_fixture( + client: MagicMock, nabu_casa_zwa2_legacy_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2 (legacy firmware).""" + node = Node(client, nabu_casa_zwa2_legacy_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json new file mode 100644 index 00000000000..662f7893493 --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json @@ -0,0 +1,231 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/data/db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "Home Assistant Connect ZWA-2", + "description": "Z-Wave Adapter", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "Home Assistant Connect ZWA-2", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 227 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 181 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 255, + "green": 227, + "blue": 181 + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 0, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "ffe3b5" + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json new file mode 100644 index 00000000000..31ca446dafc --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json @@ -0,0 +1,146 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/home/dominic/Repositories/zwavejs2mqtt/store/.config-db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "Home Assistant Connect ZWA-2", + "description": "Z-Wave Adapter", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "Home Assistant Connect ZWA-2", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 44133db03ac..200c77ce443 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -495,3 +495,51 @@ async def test_aeotec_smart_switch_7( entity_entry = entity_registry.async_get(state.entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG + + +async def test_nabu_casa_zwa2( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery.""" + state = hass.states.get("light.z_wave_adapter") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.ONOFF, + ], "The LED indicator should be an ON/OFF light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) + + +async def test_nabu_casa_zwa2_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2_legacy: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery with legacy firmware.""" + state = hass.states.get("light.z_wave_adapter") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.HS, + ], "The LED indicator should be a color light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) From 58ddf4ea95ec843e09d76bb69d04bc0ef42bfebd Mon Sep 17 00:00:00 2001 From: Marius <33354141+Mariusthvdb@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:40:09 +0200 Subject: [PATCH 067/235] Add note about re-interviewing Z-Wave battery powered devices (#149300) --- homeassistant/components/zwave_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 4d68aa2bcbc..687d06cd703 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -274,7 +274,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device specific and is normally explained in the device manual.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" From fad5f7a47b5d93bb1ff5ad4262727db3ca2a429d Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 23 Jul 2025 09:52:25 -0400 Subject: [PATCH 068/235] Move optimistic platform logic to AbstractTemplateEntity base class (#149245) --- .../components/template/binary_sensor.py | 2 +- homeassistant/components/template/cover.py | 28 +++++++++---------- homeassistant/components/template/entity.py | 19 +++++++++++-- homeassistant/components/template/select.py | 24 +++++----------- .../components/template/template_entity.py | 6 ++++ 5 files changed, 43 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index e8b8efbda0a..567e9e3a110 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -182,7 +182,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._template = config[CONF_STATE] + self._template: template.Template = config[CONF_STATE] self._delay_cancel = None self._delay_on = None self._delay_on_raw = config.get(CONF_DELAY_ON) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 0bbc6b77f57..8f88baea091 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -24,7 +24,6 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_NAME, - CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -41,6 +40,7 @@ from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -97,7 +97,6 @@ COVER_YAML_SCHEMA = vol.All( vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_POSITION): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, @@ -106,7 +105,9 @@ COVER_YAML_SCHEMA = vol.All( vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -121,7 +122,6 @@ COVER_LEGACY_YAML_SCHEMA = vol.All( vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, @@ -129,7 +129,9 @@ COVER_LEGACY_YAML_SCHEMA = vol.All( vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, } - ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), + ) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -162,21 +164,17 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) self._position_template = config.get(CONF_POSITION) self._tilt_template = config.get(CONF_TILT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - optimistic = config.get(CONF_OPTIMISTIC) - self._optimistic = optimistic or ( - optimistic is None and not self._template and not self._position_template - ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template self._position: int | None = None @@ -318,7 +316,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": 100}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 100 self.async_write_ha_state() @@ -332,7 +330,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": 0}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 0 self.async_write_ha_state() @@ -349,7 +347,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": self._position}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self.async_write_ha_state() async def async_open_cover_tilt(self, **kwargs: Any) -> None: @@ -493,10 +491,10 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): updater(rendered) write_ha_state = True - if not self._optimistic: + if not self._attr_assumed_state: self.async_set_context(self.coordinator.data["context"]) write_ha_state = True - elif self._optimistic and len(self._rendered) > 0: + elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template write_ha_state = True diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 31c48917a1f..e9a630594d7 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -4,12 +4,12 @@ from abc import abstractmethod from collections.abc import Sequence from typing import Any -from homeassistant.const import CONF_DEVICE_ID +from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType -from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.template import Template, TemplateStateFromEntityId from homeassistant.helpers.typing import ConfigType from .const import CONF_OBJECT_ID @@ -19,13 +19,26 @@ class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" _entity_id_format: str + _optimistic_entity: bool = False + _template: Template | None = None - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if self._optimistic_entity: + self._template = config.get(CONF_STATE) + + self._attr_assumed_state = self._template is None or config.get( + CONF_OPTIMISTIC, False + ) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( self._entity_id_format, object_id, hass=self.hass diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 0ad99cd6ae8..8e298c28539 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -15,7 +15,7 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE +from homeassistant.const import CONF_NAME, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -34,6 +34,7 @@ from .helpers import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -45,7 +46,6 @@ CONF_OPTIONS = "options" CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" -DEFAULT_OPTIMISTIC = False SELECT_COMMON_SCHEMA = vol.Schema( { @@ -55,15 +55,9 @@ SELECT_COMMON_SCHEMA = vol.Schema( } ) -SELECT_YAML_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } - ) - .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - .extend(SELECT_COMMON_SCHEMA.schema) -) +SELECT_YAML_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SELECT_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema @@ -117,24 +111,20 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) - self._options_template = config[ATTR_OPTIONS] - self._attr_assumed_state = self._optimistic = ( - self._template is None or config.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC) - ) self._attr_options = [] self._attr_current_option = None async def async_select_option(self, option: str) -> None: """Change the selected option.""" - if self._optimistic: + if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() if select_option := self._action_scripts.get(CONF_SELECT_OPTION): diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index ae473854502..1bc49bceafd 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_OPTIMISTIC, CONF_PATH, CONF_VARIABLES, STATE_UNKNOWN, @@ -100,6 +101,11 @@ TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) +TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = { + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, +} + + def make_template_entity_common_modern_schema( default_name: str, ) -> vol.Schema: From 23b293617474edec4df02c2b99b77f848ab3f184 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:53:36 +0200 Subject: [PATCH 069/235] Replace RuntimeError with custom ServiceValidationError in Tuya (#149175) --- homeassistant/components/tuya/humidifier.py | 16 +- homeassistant/components/tuya/number.py | 3 +- homeassistant/components/tuya/strings.json | 5 + homeassistant/components/tuya/util.py | 28 +++ tests/components/tuya/test_humidifier.py | 184 ++++++++++++++++++++ tests/components/tuya/test_number.py | 75 ++++++++ 6 files changed, 308 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 6539d98e9d8..06fdc1545c5 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,6 +21,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError @dataclass(frozen=True) @@ -169,17 +170,28 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": True}]) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": False}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self._set_humidity is None: - raise RuntimeError( - "Cannot set humidity, device doesn't provide methods to set it" + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.humidity, ) self._send_command( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 383ece6eaee..e7988adfafb 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -26,6 +26,7 @@ from .const import ( ) from .entity import TuyaEntity from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. @@ -473,7 +474,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): def set_native_value(self, value: float) -> None: """Set new value.""" if self._number is None: - raise RuntimeError("Cannot set value, device doesn't provide type data") + raise ActionDPCodeNotFoundError(self.device, self.entity_description.key) self._send_command( [ diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index abcafc490f9..954f5dbda8a 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -916,5 +916,10 @@ "name": "Siren" } } + }, + "exceptions": { + "action_dpcode_not_found": { + "message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})." + } } } diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index c1615b89c2d..916a7cfddf4 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -2,6 +2,12 @@ from __future__ import annotations +from tuya_sharing import CustomerDevice + +from homeassistant.exceptions import ServiceValidationError + +from .const import DOMAIN, DPCode + def remap_value( value: float, @@ -15,3 +21,25 @@ def remap_value( if reverse: value = from_max - value + from_min return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min + + +class ActionDPCodeNotFoundError(ServiceValidationError): + """Custom exception for action DP code not found errors.""" + + def __init__( + self, device: CustomerDevice, expected: str | DPCode | tuple[DPCode, ...] | None + ) -> None: + """Initialize the error with device and expected DP codes.""" + if expected is None: + expected = () # empty tuple for no expected codes + elif isinstance(expected, str): + expected = (DPCode(expected),) + + super().__init__( + translation_domain=DOMAIN, + translation_key="action_dpcode_not_found", + translation_placeholders={ + "expected": str(sorted([dp.value for dp in expected])), + "available": str(sorted(device.function.keys())), + }, + ) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index f4cd264a03c..d4996bcd32a 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -8,9 +8,16 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -54,3 +61,180 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_turn_on( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": True}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_turn_off( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": False}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_set_humidity( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "dehumidify_set_value", "value": 50}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_turn_on_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("[]"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_turn_off_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("[]"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_set_humidity_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['dehumidify_set_value']", + "available": ("[]"), + } diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 7da514964aa..b6c7b1f6de5 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -8,9 +8,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -53,3 +55,76 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_alarm_host"], +) +async def test_set_value( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value.""" + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": entity_id, + "value": 18, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "delay_set", "value": 18}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_alarm_host"], +) +async def test_set_value_no_function( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value when no function available.""" + + # Mock a device with delay_set in status but not in function or status_range + mock_device.function.pop("delay_set") + mock_device.status_range.pop("delay_set") + + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": entity_id, + "value": 18, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['delay_set']", + "available": ( + "['alarm_delay_time', 'alarm_time', 'master_mode', 'master_state', " + "'muffling', 'sub_admin', 'sub_class', 'switch_alarm_light', " + "'switch_alarm_propel', 'switch_alarm_sound', 'switch_kb_light', " + "'switch_kb_sound', 'switch_mode_sound']" + ), + } From b6db10340e2e14ae5969e15492caa7a0319a5765 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 23 Jul 2025 07:54:06 -0700 Subject: [PATCH 070/235] Update supported languages for Google Generative AI TTS and STT (#149154) --- .../google_generative_ai_conversation/stt.py | 147 +++++++++--------- .../google_generative_ai_conversation/tts.py | 3 + 2 files changed, 79 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py index bdf8a2fd7bf..f9b91ff6685 100644 --- a/homeassistant/components/google_generative_ai_conversation/stt.py +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -53,103 +53,51 @@ class GoogleGenerativeAISttEntity( """Return a list of supported languages.""" return [ "af-ZA", - "sq-AL", "am-ET", - "ar-DZ", + "ar-AE", "ar-BH", + "ar-DZ", "ar-EG", - "ar-IQ", "ar-IL", + "ar-IQ", "ar-JO", "ar-KW", "ar-LB", "ar-MA", "ar-OM", + "ar-PS", "ar-QA", "ar-SA", - "ar-PS", "ar-TN", - "ar-AE", "ar-YE", - "hy-AM", "az-AZ", - "eu-ES", + "bg-BG", "bn-BD", "bn-IN", "bs-BA", - "bg-BG", - "my-MM", "ca-ES", - "zh-CN", - "zh-TW", - "hr-HR", "cs-CZ", "da-DK", - "nl-BE", - "nl-NL", + "de-AT", + "de-CH", + "de-DE", + "el-GR", "en-AU", "en-CA", + "en-GB", "en-GH", "en-HK", - "en-IN", "en-IE", + "en-IN", "en-KE", - "en-NZ", "en-NG", - "en-PK", + "en-NZ", "en-PH", + "en-PK", "en-SG", - "en-ZA", "en-TZ", - "en-GB", "en-US", - "et-EE", - "fil-PH", - "fi-FI", - "fr-BE", - "fr-CA", - "fr-FR", - "fr-CH", - "gl-ES", - "ka-GE", - "de-AT", - "de-DE", - "de-CH", - "el-GR", - "gu-IN", - "iw-IL", - "hi-IN", - "hu-HU", - "is-IS", - "id-ID", - "it-IT", - "it-CH", - "ja-JP", - "jv-ID", - "kn-IN", - "kk-KZ", - "km-KH", - "ko-KR", - "lo-LA", - "lv-LV", - "lt-LT", - "mk-MK", - "ms-MY", - "ml-IN", - "mr-IN", - "mn-MN", - "ne-NP", - "no-NO", - "fa-IR", - "pl-PL", - "pt-BR", - "pt-PT", - "ro-RO", - "ru-RU", - "sr-RS", - "si-LK", - "sk-SK", - "sl-SI", + "en-ZA", "es-AR", "es-BO", "es-CL", @@ -157,27 +105,81 @@ class GoogleGenerativeAISttEntity( "es-CR", "es-DO", "es-EC", - "es-SV", + "es-ES", "es-GT", "es-HN", "es-MX", "es-NI", "es-PA", - "es-PY", "es-PE", "es-PR", - "es-ES", + "es-PY", + "es-SV", "es-US", "es-UY", "es-VE", + "et-EE", + "eu-ES", + "fa-IR", + "fi-FI", + "fil-PH", + "fr-BE", + "fr-CA", + "fr-CH", + "fr-FR", + "ga-IE", + "gl-ES", + "gu-IN", + "he-IL", + "hi-IN", + "hr-HR", + "hu-HU", + "hy-AM", + "id-ID", + "is-IS", + "it-CH", + "it-IT", + "iw-IL", + "ja-JP", + "jv-ID", + "ka-GE", + "kk-KZ", + "km-KH", + "kn-IN", + "ko-KR", + "lb-LU", + "lo-LA", + "lt-LT", + "lv-LV", + "mk-MK", + "ml-IN", + "mn-MN", + "mr-IN", + "ms-MY", + "my-MM", + "nb-NO", + "ne-NP", + "nl-BE", + "nl-NL", + "no-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "si-LK", + "sk-SK", + "sl-SI", + "sq-AL", + "sr-RS", "su-ID", + "sv-SE", "sw-KE", "sw-TZ", - "sv-SE", "ta-IN", + "ta-LK", "ta-MY", "ta-SG", - "ta-LK", "te-IN", "th-TH", "tr-TR", @@ -186,6 +188,9 @@ class GoogleGenerativeAISttEntity( "ur-PK", "uz-UZ", "vi-VN", + "zh-CN", + "zh-HK", + "zh-TW", "zu-ZA", ] diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 9bc5b0c6cb6..08e83242fcd 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -48,10 +48,13 @@ class GoogleGenerativeAITextToSpeechEntity( _attr_supported_options = [ATTR_VOICE] # See https://ai.google.dev/gemini-api/docs/speech-generation#languages + # Note the documentation might not be up to date, e.g. el-GR is not listed + # there but is supported. _attr_supported_languages = [ "ar-EG", "bn-BD", "de-DE", + "el-GR", "en-IN", "en-US", "es-US", From 391b1440338ea8ba703c4c7839e082e8a81fef21 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 23 Jul 2025 16:55:00 +0200 Subject: [PATCH 071/235] Update Z-Wave LED entity name for ZWA-2 (#149323) --- .../components/zwave_js/discovery.py | 4 +-- homeassistant/components/zwave_js/light.py | 32 ++++++++++++++++++- .../fixtures/nabu_casa_zwa2_legacy_state.json | 6 ++-- .../fixtures/nabu_casa_zwa2_state.json | 6 ++-- tests/components/zwave_js/test_discovery.py | 20 ++++++++++-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 761c80bb0bb..25c342cf87d 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -779,7 +779,7 @@ DISCOVERY_SCHEMAS = [ manufacturer_id={0x0466}, product_id={0x0001}, product_type={0x0001}, - hint="color_onoff", + hint="zwa2_led_color", primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA, absent_values=[ SWITCH_BINARY_CURRENT_VALUE_SCHEMA, @@ -793,7 +793,7 @@ DISCOVERY_SCHEMAS = [ manufacturer_id={0x0466}, product_id={0x0001}, product_type={0x0001}, - hint="onoff", + hint="zwa2_led_onoff", primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, absent_values=[ COLOR_SWITCH_CURRENT_VALUE_SCHEMA, diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index a90515cd040..9b7c0222410 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -77,7 +77,11 @@ async def async_setup_entry( driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - if info.platform_hint == "color_onoff": + if info.platform_hint == "zwa2_led_color": + async_add_entities([ZWA2LEDColorLight(config_entry, driver, info)]) + elif info.platform_hint == "zwa2_led_onoff": + async_add_entities([ZWA2LEDOnOffLight(config_entry, driver, info)]) + elif info.platform_hint == "color_onoff": async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)]) else: async_add_entities([ZwaveLight(config_entry, driver, info)]) @@ -680,3 +684,29 @@ class ZwaveColorOnOffLight(ZwaveLight): colors, kwargs.get(ATTR_TRANSITION), ) + + +class ZWA2LEDColorLight(ZwaveColorOnOffLight): + """LED entity specific to the ZWA-2 (legacy firmware).""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" + + +class ZWA2LEDOnOffLight(ZwaveLight): + """LED entity specific to the ZWA-2.""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json index 662f7893493..8ea8cdbd009 100644 --- a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json @@ -14,8 +14,8 @@ "isEmbedded": true, "manufacturer": "Nabu Casa", "manufacturerId": 1126, - "label": "Home Assistant Connect ZWA-2", - "description": "Z-Wave Adapter", + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", "devices": [ { "productType": 1, @@ -28,7 +28,7 @@ }, "preferred": false }, - "label": "Home Assistant Connect ZWA-2", + "label": "NC-ZWA-9734", "interviewAttempts": 0, "isFrequentListening": false, "maxDataRate": 100000, diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json index 31ca446dafc..e0c57462440 100644 --- a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json @@ -14,8 +14,8 @@ "isEmbedded": true, "manufacturer": "Nabu Casa", "manufacturerId": 1126, - "label": "Home Assistant Connect ZWA-2", - "description": "Z-Wave Adapter", + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", "devices": [ { "productType": 1, @@ -28,7 +28,7 @@ }, "preferred": false }, - "label": "Home Assistant Connect ZWA-2", + "label": "NC-ZWA-9734", "interviewAttempts": 0, "isFrequentListening": false, "maxDataRate": 100000, diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 200c77ce443..9109d6a4048 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -504,7 +504,7 @@ async def test_nabu_casa_zwa2( integration: MockConfigEntry, ) -> None: """Test ZWA-2 discovery.""" - state = hass.states.get("light.z_wave_adapter") + state = hass.states.get("light.home_assistant_connect_zwa_2_led") assert state, "The LED indicator should be enabled by default" entry = entity_registry.async_get(state.entity_id) @@ -520,6 +520,14 @@ async def test_nabu_casa_zwa2( "The LED indicator should be configuration" ) + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + ) + async def test_nabu_casa_zwa2_legacy( hass: HomeAssistant, @@ -528,7 +536,7 @@ async def test_nabu_casa_zwa2_legacy( integration: MockConfigEntry, ) -> None: """Test ZWA-2 discovery with legacy firmware.""" - state = hass.states.get("light.z_wave_adapter") + state = hass.states.get("light.home_assistant_connect_zwa_2_led") assert state, "The LED indicator should be enabled by default" entry = entity_registry.async_get(state.entity_id) @@ -543,3 +551,11 @@ async def test_nabu_casa_zwa2_legacy( assert entry.entity_category is EntityCategory.CONFIG, ( "The LED indicator should be configuration" ) + + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + ) From ccd22ce0d52dd624e8a08a7b85c457c53d2371ae Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Thu, 24 Jul 2025 00:55:44 +1000 Subject: [PATCH 072/235] Fix brightness_step and brightness_step_pct via lifx.set_state (#149217) Signed-off-by: Avi Miller --- homeassistant/components/lifx/light.py | 17 ++++++++++ tests/components/lifx/test_light.py | 44 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 3d30fcd369e..7a1b51ac8ae 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -10,6 +10,9 @@ import aiolifx_effects as aiolifx_effects_module import voluptuous as vol from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_EFFECT, ATTR_TRANSITION, LIGHT_TURN_ON_SCHEMA, @@ -234,6 +237,20 @@ class LIFXLight(LIFXEntity, LightEntity): else: fade = 0 + if ATTR_BRIGHTNESS_STEP in kwargs or ATTR_BRIGHTNESS_STEP_PCT in kwargs: + brightness = self.brightness if self.is_on and self.brightness else 0 + + if ATTR_BRIGHTNESS_STEP in kwargs: + brightness += kwargs.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + kwargs.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + + kwargs[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + # These are both False if ATTR_POWER is not set power_on = kwargs.get(ATTR_POWER, False) power_off = not kwargs.get(ATTR_POWER, True) diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index d66908c1b1a..edb13c259e8 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -30,6 +30,8 @@ from homeassistant.components.lifx.manager import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, @@ -1735,6 +1737,48 @@ async def test_transitions_color_bulb(hass: HomeAssistant) -> None: bulb.set_color.reset_mock() +async def test_lifx_set_state_brightness(hass: HomeAssistant) -> None: + """Test lifx.set_state works with brightness, brightness_pct and brightness_step.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb_new_firmware() + bulb.power_level = 65535 + bulb.color = [0, 0, 32768, 3500] + 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" + + # brightness_step should convert from 8 bit to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP: 128}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + # brightness_step_pct should convert from percentage to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP_PCT: 50}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + async def test_lifx_set_state_color(hass: HomeAssistant) -> None: """Test lifx.set_state works with color names and RGB.""" config_entry = MockConfigEntry( From 2abd203580b2c9092c089d5466627b5dd8081dbe Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:58:18 +0200 Subject: [PATCH 073/235] Bump eheimdigital quality scale to platinum (#148263) --- .../components/eheimdigital/manifest.json | 2 +- .../components/eheimdigital/quality_scale.yaml | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index dba4b6d563c..d414b559aa1 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["eheimdigital"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["eheimdigital==1.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml index 801e0748310..96fa798f9cf 100644 --- a/homeassistant/components/eheimdigital/quality_scale.yaml +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -46,22 +46,24 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: No repairs. stale-devices: done # Platinum From f679f33c56b46f694973af2d9d2b0d2a847e3404 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:02:59 +0200 Subject: [PATCH 074/235] Fix description of `current` field of `keba.set_current` action (#149326) --- homeassistant/components/keba/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/keba/strings.json b/homeassistant/components/keba/strings.json index 49ce01f4332..1616df6237b 100644 --- a/homeassistant/components/keba/strings.json +++ b/homeassistant/components/keba/strings.json @@ -28,7 +28,7 @@ "fields": { "current": { "name": "Current", - "description": "The maximum current used for the charging process. The value is depending on the DIP-switch settings and the used cable of the charging station." + "description": "The maximum current used for the charging process. The value depends on the DIP switch settings and the cable used by the charging station." } } }, From 61807412c41c2f00eaaabf4ee0b3e5f60fcf6812 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:03:12 +0200 Subject: [PATCH 075/235] Fix typo "optimisic" in `mqtt` (#149291) --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8cb66270331..ba869a7334b 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -422,7 +422,7 @@ "tilt_opened_value": "The value that will be sent to the \"tilt command topic\" when the cover tilt is opened.", "tilt_status_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the payload for the tilt status topic. Within the template the following variables are available: `entity_id`, `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#tilt_status_template)", "tilt_status_topic": "The MQTT topic subscribed to receive tilt status update values. [Learn more.]({url}#tilt_status_topic)", - "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimisic mode by default. [Learn more.]({url}#tilt_optimistic)" + "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimistic mode by default. [Learn more.]({url}#tilt_optimistic)" } }, "light_brightness_settings": { From 15f7dade5e858cdd3e8c9c88e6a06dc19e36f15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Wed, 23 Jul 2025 17:05:35 +0200 Subject: [PATCH 076/235] Fix warning about failure to get action during setup phase (#148923) --- homeassistant/components/wmspro/button.py | 2 +- homeassistant/components/wmspro/cover.py | 4 ++-- homeassistant/components/wmspro/light.py | 4 ++-- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py index f1ab0489b86..1b2772a9c80 100644 --- a/homeassistant/components/wmspro/button.py +++ b/homeassistant/components/wmspro/button.py @@ -23,7 +23,7 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [ WebControlProIdentifyButton(config_entry.entry_id, dest) for dest in hub.dests.values() - if dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.Identify) ] async_add_entities(entities) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index b6f100280ad..e7255d478cb 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -32,9 +32,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.AwningDrive): entities.append(WebControlProAwning(config_entry.entry_id, dest)) - elif dest.action( + elif dest.hasAction( WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive ): entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index 52d092ed9f0..2326734ceaf 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -33,9 +33,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.LightDimming): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightDimming): entities.append(WebControlProDimmer(config_entry.entry_id, dest)) - elif dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch): + elif dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightSwitch): entities.append(WebControlProLight(config_entry.entry_id, dest)) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index 9185768165a..9dbcf09a7d4 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.3.0"] + "requirements": ["pywmspro==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4837f6e88b2..884a54a9f92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,7 +2603,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f928f1e2054..1f6cc235624 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2161,7 +2161,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 From 8b7295cd26fc1de4762f192b61acd009ce62ba40 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:16:28 +0200 Subject: [PATCH 077/235] Fix three spelling issues in `lg_thinq` (#149322) --- homeassistant/components/lg_thinq/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 65e36a4523e..402816466ea 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -74,7 +74,7 @@ }, "binary_sensor": { "eco_friendly_mode": { - "name": "Eco friendly" + "name": "Eco-friendly" }, "power_save_enabled": { "name": "Power saving mode" @@ -149,7 +149,7 @@ "cliff_error": "Fall prevention sensor has an error", "clutch_error": "Clutch error", "compressor_error": "Compressor error", - "dispensing_error": "Dispensor error", + "dispensing_error": "Dispenser error", "door_close_error": "Door closed error", "door_lock_error": "Door lock error", "door_open_error": "Door open", @@ -233,7 +233,7 @@ "styling_is_complete": "Styling is completed", "time_to_change_filter": "It is time to replace the filter", "time_to_change_water_filter": "You need to replace water filter", - "time_to_clean": "Need to selfcleaning", + "time_to_clean": "Need for self-cleaning", "time_to_clean_filter": "It is time to clean the filter", "timer_is_complete": "Timer has been completed", "washing_is_complete": "Washing is completed", From 5b94f5a99a68623f463b63222249d77015ffbcf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 23 Jul 2025 17:33:24 +0200 Subject: [PATCH 078/235] Add more types in TYPE_MAP for Matter Cover (#149188) --- homeassistant/components/matter/cover.py | 6 ++++++ tests/components/matter/snapshots/test_cover.ambr | 12 ++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 2e2d4390b30..7bef7ea1853 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -31,8 +31,14 @@ OPERATIONAL_STATUS_MASK = 0b11 # map Matter window cover types to HA device class TYPE_MAP = { + clusters.WindowCovering.Enums.Type.kRollerShade: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShade2Motor: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShadeExterior: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShadeExterior2Motor: CoverDeviceClass.SHADE, clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING, clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN, + clusters.WindowCovering.Enums.Type.kTiltBlindTiltOnly: CoverDeviceClass.BLIND, + clusters.WindowCovering.Enums.Type.kTiltBlindLiftAndTilt: CoverDeviceClass.BLIND, } diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index c8e2c03739a..c0b38a58456 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -124,7 +124,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -140,7 +140,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 51, - 'device_class': 'awning', + 'device_class': 'shade', 'friendly_name': 'Longan link WNCV DA01', 'supported_features': , }), @@ -175,7 +175,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -191,7 +191,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_tilt_position': 100, - 'device_class': 'awning', + 'device_class': 'blind', 'friendly_name': 'Mock PA Tilt Window Covering', 'supported_features': , }), @@ -226,7 +226,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -241,7 +241,7 @@ # name: test_covers[window_covering_tilt][cover.mock_tilt_window_covering-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'awning', + 'device_class': 'blind', 'friendly_name': 'Mock Tilt Window Covering', 'supported_features': , }), From 45edd12f138f5c4ff111bed9bd0e0fb115b56bef Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 23 Jul 2025 17:51:24 +0200 Subject: [PATCH 079/235] Bump `imgw_pib` to version 1.5.0 (#149324) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 7b7c66a953d..79118d10de6 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.4.2"] + "requirements": ["imgw_pib==1.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 884a54a9f92..15ea7aab52d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1237,7 +1237,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.4.2 +imgw_pib==1.5.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f6cc235624..4075ecd3b8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.4.2 +imgw_pib==1.5.0 # homeassistant.components.incomfort incomfort-client==0.6.9 From e337abb12d47c69062c73812a3239a1e2044ede0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:57:45 +0200 Subject: [PATCH 080/235] Clarify setup description in `google_travel_time` (#149327) --- homeassistant/components/google_travel_time/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index f46d33fda09..b114c3d9225 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name (case sensitive)", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or a zone's friendly name (case-sensitive)", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", From d735af505e5e296d9dc7cdc22fa790f8071fa6ea Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:04:47 +0200 Subject: [PATCH 081/235] Sentence-case "app" in `laundrify` (#149328) --- homeassistant/components/laundrify/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json index 481900775ae..600e6a9bdf0 100644 --- a/homeassistant/components/laundrify/strings.json +++ b/homeassistant/components/laundrify/strings.json @@ -9,7 +9,7 @@ "config": { "step": { "init": { - "description": "Please enter your personal Auth Code that is shown in the laundrify-App.", + "description": "Please enter your personal Auth Code that is shown in the laundrify app.", "data": { "code": "Auth Code (xxx-xxx)" } From 3ed297676f742756f7606f6e1687d9f9121ffbaf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:08:01 +0200 Subject: [PATCH 082/235] Remove third "s" from "Home Assistant" in `lametric` (#149329) --- homeassistant/components/lametric/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index dbf25f6680b..f3fa1e81112 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "choice_enter_manual_or_fetch_cloud": { - "description": "A LaMetric device can be set up in Home Assistant in two different ways.\n\nYou can enter all device information and API tokens yourself, or Home Asssistant can import them from your LaMetric.com account.", + "description": "A LaMetric device can be set up in Home Assistant in two different ways.\n\nYou can enter all device information and API tokens yourself, or Home Assistant can import them from your LaMetric.com account.", "menu_options": { "pick_implementation": "Import from LaMetric.com (recommended)", "manual_entry": "Enter manually" From 5aa629edd09052cca52df06cdaa880ddc12db1da Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:16:00 +0200 Subject: [PATCH 083/235] Fix typo in "re-authentication" in `devolo_home_network` (#149312) --- homeassistant/components/devolo_home_network/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 24bf06ac59c..c8c2db34e4c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -105,7 +105,7 @@ "message": "Device {title} did not respond" }, "password_protected": { - "message": "Device {title} requires re-authenticatication to set or change the password" + "message": "Device {title} requires re-authentication to set or change the password" }, "password_wrong": { "message": "The used password is wrong" From d3771571cdc7985e03efeaed855b24d260ada2a0 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 23 Jul 2025 18:18:41 +0200 Subject: [PATCH 084/235] Bump knx-frontend (#149287) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 5145d2d22f8..6a4565dde0e 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.6.13.181749" + "knx-frontend==2025.7.23.50952" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 15ea7aab52d..f321f6e01e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1304,7 +1304,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.6.13.181749 +knx-frontend==2025.7.23.50952 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4075ecd3b8d..652d041c43b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1126,7 +1126,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.6.13.181749 +knx-frontend==2025.7.23.50952 # homeassistant.components.konnected konnected==1.2.0 From 1312e04c5793bdf0371cb52d2f064c858e5b28c0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:53:19 +0200 Subject: [PATCH 085/235] Fix typos in `update_failed` message of `fritz` (#149330) --- homeassistant/components/fritz/strings.json | 2 +- tests/components/fritz/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index ee23a8cfbef..45d66e9621b 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -214,7 +214,7 @@ "message": "Unable to establish a connection" }, "update_failed": { - "message": "Error while uptaing the data: {error}" + "message": "Error while updating the data: {error}" } } } diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 1b10ddb8fc1..4b352ccb8da 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -57,7 +57,7 @@ async def test_sensor_update_fail( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) await hass.async_block_till_done(wait_background_tasks=True) - assert "Error while uptaing the data: Boom" in caplog.text + assert "Error while updating the data: Boom" in caplog.text sensors = hass.states.async_all(SENSOR_DOMAIN) for sensor in sensors: From bfa7ff3ede3a0eea7445f2c01904e7bf598aaa6c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 19:07:40 +0200 Subject: [PATCH 086/235] Make spelling of "Telldus Live" consistent (#149332) --- homeassistant/components/tellduslive/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index b0750a7785d..17aac10063c 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,8 +11,8 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", - "title": "Authenticate with TelldusLive" + "description": "To link your Telldus Live account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n[Link Telldus Live account]({auth_url})", + "title": "Authenticate with Telldus Live" }, "user": { "data": { From b5190788aca909886b210148f0092a9d6883662c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 19:29:31 +0200 Subject: [PATCH 087/235] Fix missing sentence-casing of "MAC address" in `anthemav` (#149333) --- homeassistant/components/anthemav/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anthemav/strings.json b/homeassistant/components/anthemav/strings.json index 15e365b3e63..774785f9d29 100644 --- a/homeassistant/components/anthemav/strings.json +++ b/homeassistant/components/anthemav/strings.json @@ -10,7 +10,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on" + "cannot_receive_deviceinfo": "Failed to retrieve MAC address. Make sure the device is turned on" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" From da8ce52ed7e362b0b6c4546aae793a2a978310e5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 20:00:55 +0200 Subject: [PATCH 088/235] Fix grammar issues in re-interview description of `zwave_js` (#149337) --- homeassistant/components/zwave_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 687d06cd703..0288fbd7131 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -274,7 +274,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device specific and is normally explained in the device manual.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and the device must be re-interviewed to pick up the changes.\n\nThis is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery-powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device-specific and is normally explained in the device manual.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" From 40cf47ae5a929f5214cd3879f91c748856bac443 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 23 Jul 2025 20:48:04 +0200 Subject: [PATCH 089/235] Bump aioimmich to 0.11.1 (#149335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 16ae1671e3a..6fa8210b878 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "silver", - "requirements": ["aioimmich==0.11.0"] + "requirements": ["aioimmich==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f321f6e01e0..ab474d4a9ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -283,7 +283,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.11.0 +aioimmich==0.11.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 652d041c43b..87a8212d214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -268,7 +268,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.11.0 +aioimmich==0.11.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From b966b59c099e2e54f7c9522eb5816af1e20cd52d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 24 Jul 2025 01:37:34 +0200 Subject: [PATCH 090/235] Unifiprotect public api snapshot (#149213) --- homeassistant/components/unifiprotect/camera.py | 2 +- tests/components/unifiprotect/test_camera.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 3947324fd73..aa05ec70dd0 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -247,7 +247,7 @@ class ProtectCamera(ProtectDeviceEntity, Camera): if self.channel.is_package: last_image = await self.device.get_package_snapshot(width, height) else: - last_image = await self.device.get_snapshot(width, height) + last_image = await self.device.get_public_api_snapshot() self._last_image = last_image return self._last_image diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 34a1d064547..9c78e09d264 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -396,10 +396,10 @@ async def test_camera_image( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - ufp.api.get_camera_snapshot = AsyncMock() + ufp.api.get_public_api_camera_snapshot = AsyncMock() await async_get_image(hass, "camera.test_camera_high_resolution_channel") - ufp.api.get_camera_snapshot.assert_called_once() + ufp.api.get_public_api_camera_snapshot.assert_called_once() async def test_package_camera_image( From 3f77c13aad0062fc123fb96108e107816e4a6d12 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 02:46:20 +0200 Subject: [PATCH 091/235] Fix spelling of "re-authenticate" in `devolo_home_control` (#149342) --- homeassistant/components/devolo_home_control/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index 4ec1a35ece2..057faa446e6 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -61,7 +61,7 @@ "message": "Failed to connect to devolo Home Control central unit {gateway_id}." }, "invalid_auth": { - "message": "Authentication failed. Please re-authenticaticate with your mydevolo account." + "message": "Authentication failed. Please re-authenticate with your mydevolo account." }, "maintenance": { "message": "devolo Home Control is currently in maintenance mode." From 7613880645b9afa0348d4b4baaa63df12b163fd8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 02:50:39 +0200 Subject: [PATCH 092/235] Fix spelling of "the setup" in `nest` (#149345) --- homeassistant/components/nest/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 1fc3de9be6b..636a3a0d294 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -55,7 +55,7 @@ "description": "The Nest integration needs to re-authenticate your account" }, "oauth_discovery": { - "description": "Home Assistant has found a Google Nest device on your network. Be aware that the set up of Google Nest is more complicated than most other integrations and requires setting up a Nest Device Access project which **requires paying Google a US $5 fee**. Press **Submit** to continue setting up Google Nest." + "description": "Home Assistant has found a Google Nest device on your network. Be aware that the setup of Google Nest is more complicated than most other integrations and requires setting up a Nest Device Access project which **requires paying Google a US $5 fee**. Press **Submit** to continue setting up Google Nest." } }, "error": { From 202d8ac802c8f985f6da31622721dd73a4ccf7fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Jul 2025 20:18:59 -1000 Subject: [PATCH 093/235] Bump yalexs-ble to 3.1.0 (#149352) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/yalexs_ble/test_config_flow.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 9dc66084a45..2368c848eea 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==8.10.0", "yalexs-ble==3.0.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index fee5b0b8310..5b45628ee64 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.0.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index b3021bd908e..7a02afbc5d7 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==3.0.0"] + "requirements": ["yalexs-ble==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab474d4a9ce..7f9bd458716 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3157,7 +3157,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.0.0 +yalexs-ble==3.1.0 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87a8212d214..9d2d7390c39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2607,7 +2607,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.0.0 +yalexs-ble==3.1.0 # homeassistant.components.august # homeassistant.components.yale diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index 1b0df05db2c..c272036097d 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -37,7 +37,7 @@ def _get_mock_push_lock(): mock_push_lock.wait_for_first_update = AsyncMock() mock_push_lock.stop = AsyncMock() mock_push_lock.lock_state = LockState( - LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None + LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None, None, None ) mock_push_lock.lock_status = LockStatus.UNLOCKED mock_push_lock.door_status = DoorStatus.CLOSED From 5543587527326235beb4fe880ee51df7635cbe33 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 08:22:25 +0200 Subject: [PATCH 094/235] Fix spelling of "sea level" in `luftdaten` (#149347) --- homeassistant/components/luftdaten/strings.json | 2 +- tests/components/luftdaten/test_sensor.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index ea842f18ebd..072252cdf21 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -19,7 +19,7 @@ }, "entity": { "sensor": { - "pressure_at_sealevel": { "name": "Pressure at sealevel" } + "pressure_at_sealevel": { "name": "Pressure at sea level" } } } } diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py index f2cf12b3fda..bbabc486355 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -72,16 +72,16 @@ async def test_luftdaten_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.PA assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("sensor.sensor_12345_pressure_at_sealevel") + entry = entity_registry.async_get("sensor.sensor_12345_pressure_at_sea_level") assert entry assert entry.device_id assert entry.unique_id == "12345_pressure_at_sealevel" - state = hass.states.get("sensor.sensor_12345_pressure_at_sealevel") + state = hass.states.get("sensor.sensor_12345_pressure_at_sea_level") assert state assert state.state == "103102.13" assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 Pressure at sealevel" + state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 Pressure at sea level" ) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT From c2b1932045e9016490acb2d45e9a65cb0daa33d8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 24 Jul 2025 08:23:02 +0200 Subject: [PATCH 095/235] Bump aioonkyo to 0.3.0 (#149336) --- homeassistant/components/onkyo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 07834d4cba1..e465c99052f 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioonkyo"], - "requirements": ["aioonkyo==0.2.0"], + "requirements": ["aioonkyo==0.3.0"], "ssdp": [ { "manufacturer": "ONKYO", diff --git a/requirements_all.txt b/requirements_all.txt index 7f9bd458716..e24f1bcd209 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -331,7 +331,7 @@ aiontfy==0.5.3 aionut==4.3.4 # homeassistant.components.onkyo -aioonkyo==0.2.0 +aioonkyo==0.3.0 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d2d7390c39..dffabce4fbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -313,7 +313,7 @@ aiontfy==0.5.3 aionut==4.3.4 # homeassistant.components.onkyo -aioonkyo==0.2.0 +aioonkyo==0.3.0 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 From 55f01e34859a97501dd4a8a7d797a2f58b187eff Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 08:23:42 +0200 Subject: [PATCH 096/235] Make descriptions of `modbus.stop`/`restart` actions consistent (#149341) --- homeassistant/components/modbus/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 7d1578558b0..0749ba4a2c8 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -50,7 +50,7 @@ }, "stop": { "name": "[%key:common::action::stop%]", - "description": "Stops modbus hub.", + "description": "Stops a Modbus hub.", "fields": { "hub": { "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", @@ -60,7 +60,7 @@ }, "restart": { "name": "[%key:common::action::restart%]", - "description": "Restarts modbus hub (if running stop then start).", + "description": "Restarts a Modbus hub (if running, stops then starts).", "fields": { "hub": { "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", From 049a6988159aa635c92f88cc2316415abad39e5b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 08:56:49 +0200 Subject: [PATCH 097/235] Add missing hyphen to "right-hand drive" in `teslemetry` (#149355) --- homeassistant/components/teslemetry/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 57b6053bb48..646a3898cc7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -192,7 +192,7 @@ "name": "European vehicle" }, "right_hand_drive": { - "name": "Right hand drive" + "name": "Right-hand drive" }, "located_at_home": { "name": "Located at home" From fcd514a06b1ed193327a69e01d25e2d1309b5078 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 09:23:21 +0200 Subject: [PATCH 098/235] Sentence-case "Still image URL" in `mjpeg` (#149356) --- homeassistant/components/mjpeg/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mjpeg/strings.json b/homeassistant/components/mjpeg/strings.json index 0e1e71fd82c..ed53f6bcdc9 100644 --- a/homeassistant/components/mjpeg/strings.json +++ b/homeassistant/components/mjpeg/strings.json @@ -6,7 +6,7 @@ "mjpeg_url": "MJPEG URL", "name": "[%key:common::config_flow::data::name%]", "password": "[%key:common::config_flow::data::password%]", - "still_image_url": "Still Image URL", + "still_image_url": "Still image URL", "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } From 53d77c4c1065c2e6cc6a77498a7afd444527ff8f Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 24 Jul 2025 01:08:58 -0700 Subject: [PATCH 099/235] Fix Chinese in Google Cloud STT (#149155) --- homeassistant/components/google_cloud/const.py | 10 ++++++++++ homeassistant/components/google_cloud/stt.py | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index 16b1463f0f3..3a0b2bc4832 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -186,3 +186,13 @@ STT_LANGUAGES = [ "yue-Hant-HK", "zu-ZA", ] + +# This allows us to support HA's standard codes (e.g., zh-CN) while +# sending the correct code to the Google API (e.g., cmn-Hans-CN). +HA_TO_GOOGLE_STT_LANG_MAP = { + "zh-CN": "cmn-Hans-CN", # Chinese (Mandarin, Simplified, China) + "zh-HK": "yue-Hant-HK", # Chinese (Cantonese, Traditional, Hong Kong) + "zh-TW": "cmn-Hant-TW", # Chinese (Mandarin, Traditional, Taiwan) + "he-IL": "iw-IL", # Hebrew (Google uses 'iw' legacy code) + "nb-NO": "no-NO", # Norwegian Bokmål +} diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 8a548cde8bb..ea438b01cdd 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -8,6 +8,7 @@ import logging from google.api_core.exceptions import GoogleAPIError, Unauthenticated from google.api_core.retry import AsyncRetry from google.cloud import speech_v1 +from propcache.api import cached_property from homeassistant.components.stt import ( AudioBitRates, @@ -30,6 +31,7 @@ from .const import ( CONF_STT_MODEL, DEFAULT_STT_MODEL, DOMAIN, + HA_TO_GOOGLE_STT_LANG_MAP, STT_LANGUAGES, ) @@ -68,10 +70,14 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): self._client = client self._model = entry.options.get(CONF_STT_MODEL, DEFAULT_STT_MODEL) - @property + @cached_property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return STT_LANGUAGES + # Combine the native Google languages and the standard HA languages. + # A set is used to automatically handle duplicates. + supported = set(STT_LANGUAGES) + supported.update(HA_TO_GOOGLE_STT_LANG_MAP.keys()) + return sorted(supported) @property def supported_formats(self) -> list[AudioFormats]: @@ -102,6 +108,10 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" + language_code = HA_TO_GOOGLE_STT_LANG_MAP.get( + metadata.language, metadata.language + ) + streaming_config = speech_v1.StreamingRecognitionConfig( config=speech_v1.RecognitionConfig( encoding=( @@ -110,7 +120,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): else speech_v1.RecognitionConfig.AudioEncoding.LINEAR16 ), sample_rate_hertz=metadata.sample_rate, - language_code=metadata.language, + language_code=language_code, model=self._model, ) ) From 46a01c2060df117ee98b927fe8c399984073d4b2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 10:23:17 +0200 Subject: [PATCH 100/235] Fix config entry name and description in `rainbird.set_rain_delay` action (#149358) --- homeassistant/components/rainbird/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6f92b1bdb97..ca7dc18b8d8 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -80,8 +80,8 @@ "description": "Sets how long automatic irrigation is turned off.", "fields": { "config_entry_id": { - "name": "Rainbird Controller Configuration Entry", - "description": "The setting will be adjusted on the specified controller." + "name": "Rain Bird controller", + "description": "The configuration entry of the controller to adjust the setting." }, "duration": { "name": "Duration", From 2e12d67f2fe47ed66e53ea5786fd7c711d1afed3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 10:23:30 +0200 Subject: [PATCH 101/235] Improve `id_missing` abort message in `samsungtv` (#149357) --- homeassistant/components/samsungtv/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 6251e65b2f8..aa0e77e0b76 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -50,7 +50,7 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Check your TV's External Device Manager settings to authorize Home Assistant.", - "id_missing": "This Samsung device doesn't have a SerialNumber.", + "id_missing": "This Samsung device doesn't have a serial number to identify it.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "not_supported": "This Samsung device is currently not supported.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" From d85ffee27a5cd85e3131d3b0a01a6ad0ce8beaad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 10:29:34 +0200 Subject: [PATCH 102/235] Bump github/codeql-action from 3.29.3 to 3.29.4 (#149354) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cbc343b9d98..cc6014b38b0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.3 + uses: github/codeql-action/init@v3.29.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.3 + uses: github/codeql-action/analyze@v3.29.4 with: category: "/language:python" From f458ede468feadd88a8bab08acdf3361eed42c8b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 10:53:33 +0200 Subject: [PATCH 103/235] Small fixes to user-facing strings of `webostv` (#149359) --- homeassistant/components/webostv/strings.json | 12 ++++++------ tests/components/webostv/test_trigger.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index f6d033af632..2f0a413754e 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -12,7 +12,7 @@ } }, "pairing": { - "title": "LG webOS TV Pairing", + "title": "LG webOS TV pairing", "description": "Select **Submit** and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { @@ -37,7 +37,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "wrong_device": "The configured device is not the same found on this Hostname or IP address." + "wrong_device": "The configured device is not the same found at this hostname or IP address." } }, "options": { @@ -70,7 +70,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of the webostv entities where to run the API method." + "description": "Name(s) of the webOS TV entities where to run the API method." }, "button": { "name": "Button", @@ -92,7 +92,7 @@ }, "payload": { "name": "Payload", - "description": "An optional payload to provide to the endpoint in the format of key value pair(s)." + "description": "An optional payload to provide to the endpoint in the format of key value pairs." } } }, @@ -102,7 +102,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of the webostv entities to change sound output on." + "description": "Name(s) of the webOS TV entities to change sound output on." }, "sound_output": { "name": "Sound output", @@ -134,7 +134,7 @@ "message": "Unknown trigger platform: {platform}" }, "invalid_entity_id": { - "message": "Entity {entity_id} is not a valid webostv entity." + "message": "Entity {entity_id} is not a valid webOS TV entity." }, "source_not_found": { "message": "Source {source} not found in the sources list for {name}." diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index c7decafff73..646b8f8034a 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -182,4 +182,4 @@ async def test_trigger_invalid_entity_id( }, ) - assert f"Entity {invalid_entity} is not a valid {DOMAIN} entity" in caplog.text + assert f"Entity {invalid_entity} is not a valid webOS TV entity" in caplog.text From 15f2ae300298299a2e01b10d2942e55ad7ffa454 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:03:02 +0200 Subject: [PATCH 104/235] Mark Onkyo quality scale as bronze (#149362) --- homeassistant/components/onkyo/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index e465c99052f..6102f8f2495 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -8,6 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioonkyo"], + "quality_scale": "bronze", "requirements": ["aioonkyo==0.3.0"], "ssdp": [ { diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3008c6303ff..04812e9aefa 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1777,7 +1777,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ombi", "omnilogic", "oncue", - "onkyo", "ondilo_ico", "onewire", "onvif", From f5718e1df68c4d1eb8a92a4efa0f5bacb1014d24 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 11:15:57 +0200 Subject: [PATCH 105/235] Fix spelling of "autoplay" in `music_assistant` (#149364) --- homeassistant/components/music_assistant/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index c41bfa70d4c..37f0a8e9a85 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -102,7 +102,7 @@ "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used." }, "auto_play": { - "name": "Auto play", + "name": "Autoplay", "description": "Start playing the queue on the target player. Omit to use the default behavior." } } From 393087cf507f416cff677c40159b655933148f77 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 11:50:26 +0200 Subject: [PATCH 106/235] Bump `aioshelly` to 13.8.0 (#149365) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 08c9163bb3b..78fc8261bfe 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.7.2"], + "requirements": ["aioshelly==13.8.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index e24f1bcd209..cd87da623ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,7 +384,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.2 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dffabce4fbf..05469693143 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.2 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 From eea22d8079df800581a137512ad0e3b45e86be33 Mon Sep 17 00:00:00 2001 From: Avery <130164016+avedor@users.noreply.github.com> Date: Thu, 24 Jul 2025 06:29:07 -0400 Subject: [PATCH 107/235] Add config flow for datadog (#148104) Co-authored-by: G Johansson --- homeassistant/components/datadog/__init__.py | 100 +++++--- .../components/datadog/config_flow.py | 185 ++++++++++++++ homeassistant/components/datadog/const.py | 10 + .../components/datadog/manifest.json | 1 + homeassistant/components/datadog/strings.json | 56 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/datadog/common.py | 35 +++ tests/components/datadog/test_config_flow.py | 229 ++++++++++++++++++ tests/components/datadog/test_init.py | 115 +++++++-- 10 files changed, 673 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/datadog/config_flow.py create mode 100644 homeassistant/components/datadog/const.py create mode 100644 homeassistant/components/datadog/strings.json create mode 100644 tests/components/datadog/common.py create mode 100644 tests/components/datadog/test_config_flow.py diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index fa852399b09..606f34c9ae0 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -2,9 +2,10 @@ import logging -from datadog import initialize, statsd +from datadog import DogStatsd, initialize import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -17,14 +18,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType +from . import config_flow as config_flow +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) -CONF_RATE = "rate" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 8125 -DEFAULT_PREFIX = "hass" -DEFAULT_RATE = 1 -DOMAIN = "datadog" +type DatadogConfigEntry = ConfigEntry[DogStatsd] CONFIG_SCHEMA = vol.Schema( { @@ -43,63 +49,85 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Datadog component.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Datadog integration from YAML, initiating config flow import.""" + if DOMAIN not in config: + return True - conf = config[DOMAIN] - host = conf[CONF_HOST] - port = conf[CONF_PORT] - sample_rate = conf[CONF_RATE] - prefix = conf[CONF_PREFIX] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Set up Datadog from a config entry.""" + + data = entry.data + options = entry.options + host = data[CONF_HOST] + port = data[CONF_PORT] + prefix = options[CONF_PREFIX] + sample_rate = options[CONF_RATE] + + statsd_client = DogStatsd(host=host, port=port, namespace=prefix) + entry.runtime_data = statsd_client initialize(statsd_host=host, statsd_port=port) def logbook_entry_listener(event): - """Listen for logbook entries and send them as events.""" name = event.data.get("name") message = event.data.get("message") - statsd.event( + entry.runtime_data.event( title="Home Assistant", - text=f"%%% \n **{name}** {message} \n %%%", + message=f"%%% \n **{name}** {message} \n %%%", tags=[ f"entity:{event.data.get('entity_id')}", f"domain:{event.data.get('domain')}", ], ) - _LOGGER.debug("Sent event %s", event.data.get("entity_id")) - def state_changed_listener(event): - """Listen for new messages on the bus and sends them to Datadog.""" state = event.data.get("new_state") - if state is None or state.state == STATE_UNKNOWN: return - states = dict(state.attributes) metric = f"{prefix}.{state.domain}" tags = [f"entity:{state.entity_id}"] - for key, value in states.items(): - if isinstance(value, (float, int)): - attribute = f"{metric}.{key.replace(' ', '_')}" + for key, value in state.attributes.items(): + if isinstance(value, (float, int, bool)): value = int(value) if isinstance(value, bool) else value - statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags) + attribute = f"{metric}.{key.replace(' ', '_')}" + entry.runtime_data.gauge( + attribute, value, sample_rate=sample_rate, tags=tags + ) try: value = state_helper.state_as_number(state) + entry.runtime_data.gauge(metric, value, sample_rate=sample_rate, tags=tags) except ValueError: - _LOGGER.debug("Error sending %s: %s (tags: %s)", metric, state.state, tags) - return + pass - statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug("Sent metric %s: %s (tags: %s)", metric, value, tags) - - hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) - hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) + entry.async_on_unload( + hass.bus.async_listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) + ) + entry.async_on_unload( + hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed_listener) + ) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Unload a Datadog config entry.""" + runtime = entry.runtime_data + runtime.flush() + runtime.close_socket() + return True diff --git a/homeassistant/components/datadog/config_flow.py b/homeassistant/components/datadog/config_flow.py new file mode 100644 index 00000000000..b4486b0967c --- /dev/null +++ b/homeassistant/components/datadog/config_flow.py @@ -0,0 +1,185 @@ +"""Config flow for Datadog.""" + +from typing import Any + +from datadog import DogStatsd +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + + +class DatadogConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Datadog.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user config flow.""" + errors: dict[str, str] = {} + if user_input: + # Validate connection to Datadog Agent + success = await validate_datadog_connection( + self.hass, + user_input, + ) + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + if not success: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=f"Datadog {user_input['host']}", + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + options={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_PREFIX, default=DEFAULT_PREFIX): str, + vol.Required(CONF_RATE, default=DEFAULT_RATE): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + # Check for duplicates + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + + result = await self.async_step_user(user_input) + + if errors := result.get("errors"): + await deprecate_yaml_issue(self.hass, False) + return self.async_abort(reason=errors["base"]) + + await deprecate_yaml_issue(self.hass, True) + return result + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow handler.""" + return DatadogOptionsFlowHandler() + + +class DatadogOptionsFlowHandler(OptionsFlow): + """Handle Datadog options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the Datadog options.""" + errors: dict[str, str] = {} + data = self.config_entry.data + options = self.config_entry.options + + if user_input is None: + user_input = {} + + success = await validate_datadog_connection( + self.hass, + {**data, **user_input}, + ) + if success: + return self.async_create_entry( + data={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + } + ) + + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_PREFIX, default=options[CONF_PREFIX]): str, + vol.Required(CONF_RATE, default=options[CONF_RATE]): int, + } + ), + errors=errors, + ) + + +async def validate_datadog_connection( + hass: HomeAssistant, user_input: dict[str, Any] +) -> bool: + """Attempt to send a test metric to the Datadog agent.""" + try: + client = DogStatsd(user_input[CONF_HOST], user_input[CONF_PORT]) + await hass.async_add_executor_job(client.increment, "connection_test") + except (OSError, ValueError): + return False + else: + return True + + +async def deprecate_yaml_issue( + hass: HomeAssistant, + import_success: bool, +) -> None: + """Create an issue to deprecate YAML config.""" + if import_success: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.2.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_connection_error", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_connection_error", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + "url": f"/config/integrations/dashboard/add?domain={DOMAIN}", + }, + ) diff --git a/homeassistant/components/datadog/const.py b/homeassistant/components/datadog/const.py new file mode 100644 index 00000000000..e9e5d80eeba --- /dev/null +++ b/homeassistant/components/datadog/const.py @@ -0,0 +1,10 @@ +"""Constants for the Datadog integration.""" + +DOMAIN = "datadog" + +CONF_RATE = "rate" + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8125 +DEFAULT_PREFIX = "hass" +DEFAULT_RATE = 1 diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index ca9681effca..815446b9ab4 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -2,6 +2,7 @@ "domain": "datadog", "name": "Datadog", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/datadog", "iot_class": "local_push", "loggers": ["datadog"], diff --git a/homeassistant/components/datadog/strings.json b/homeassistant/components/datadog/strings.json new file mode 100644 index 00000000000..86bb2019fc1 --- /dev/null +++ b/homeassistant/components/datadog/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Datadog Agent's address and port.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "prefix": "Prefix", + "rate": "Rate" + }, + "data_description": { + "host": "The hostname or IP address of the Datadog Agent.", + "port": "Port the Datadog Agent is listening on", + "prefix": "Metric prefix to use", + "rate": "The sample rate of UDP packets sent to Datadog." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "options": { + "step": { + "init": { + "description": "Update the Datadog configuration.", + "data": { + "prefix": "[%key:component::datadog::config::step::user::data::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data::rate%]" + }, + "data_description": { + "prefix": "[%key:component::datadog::config::step::user::data_description::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data_description::rate%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml_import_connection_error": { + "title": "{domain} YAML configuration import failed", + "description": "There was an error connecting to the Datadog Agent when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the {domain} configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 49695b695ac..d9fd32d204b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -124,6 +124,7 @@ FLOWS = { "cpuspeed", "crownstone", "daikin", + "datadog", "deako", "deconz", "deluge", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 431ece3f81a..33cc637b8a8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1171,7 +1171,7 @@ "datadog": { "name": "Datadog", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "ddwrt": { diff --git a/tests/components/datadog/common.py b/tests/components/datadog/common.py new file mode 100644 index 00000000000..07539dc0e07 --- /dev/null +++ b/tests/components/datadog/common.py @@ -0,0 +1,35 @@ +"""Common helpers for the datetime entity component tests.""" + +from unittest import mock + +MOCK_DATA = { + "host": "localhost", + "port": 8125, +} + +MOCK_OPTIONS = { + "prefix": "hass", + "rate": 1, +} + +MOCK_CONFIG = {**MOCK_DATA, **MOCK_OPTIONS} + +MOCK_YAML_INVALID = { + "host": "127.0.0.1", + "port": 65535, + "prefix": "failtest", + "rate": 1, +} + + +CONNECTION_TEST_METRIC = "connection_test" + + +def create_mock_state(entity_id, state, attributes=None): + """Helper to create a mock state object.""" + mock_state = mock.MagicMock() + mock_state.entity_id = entity_id + mock_state.state = state + mock_state.domain = entity_id.split(".")[0] + mock_state.attributes = attributes or {} + return mock_state diff --git a/tests/components/datadog/test_config_flow.py b/tests/components/datadog/test_config_flow.py new file mode 100644 index 00000000000..7950bb2c17d --- /dev/null +++ b/tests/components/datadog/test_config_flow.py @@ -0,0 +1,229 @@ +"""Tests for the Datadog config flow.""" + +from unittest.mock import MagicMock, patch + +from homeassistant.components import datadog +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import homeassistant.helpers.issue_registry as ir + +from .common import MOCK_CONFIG, MOCK_DATA, MOCK_OPTIONS, MOCK_YAML_INVALID + +from tests.common import MockConfigEntry + + +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test user-initiated config flow.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": "user"} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["title"] == f"Datadog {MOCK_CONFIG['host']}" + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == MOCK_DATA + assert result2["options"] == MOCK_OPTIONS + + +async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> None: + """Test connection failure.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("Connection failed"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": "user"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_DATA + assert result3["options"] == MOCK_OPTIONS + + +async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test that the options flow shows an error when connection fails.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection failed"), + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_OPTIONS + + +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers config flow and is accepted.""" + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA + assert result["options"] == MOCK_OPTIONS + + await hass.async_block_till_done() + + # Deprecation issue should be created + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_datadog" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_import_connection_error( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers connection error issue.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection refused"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_YAML_INVALID, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + issue = issue_registry.async_get_issue( + datadog.DOMAIN, "deprecated_yaml_import_connection_error" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml_import_connection_error" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test updating options after setup.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + new_options = { + "prefix": "updated", + "rate": 5, + } + + # OSError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # ValueError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=ValueError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Success Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == new_options + mock_instance.increment.assert_called_once_with("connection_test") + + +async def test_import_flow_abort_already_configured_service( + hass: HomeAssistant, +) -> None: + """Abort import if the same host/port is already configured.""" + existing_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": "import"}, + data=MOCK_CONFIG, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 3b7bea3c926..73bce96d16c 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -4,11 +4,15 @@ from unittest import mock from unittest.mock import patch from homeassistant.components import datadog -from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON +from homeassistant.components.datadog import async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component +from .common import MOCK_DATA, MOCK_OPTIONS, create_mock_state + +from tests.common import EVENT_STATE_CHANGED, MockConfigEntry, assert_setup_component async def test_invalid_config(hass: HomeAssistant) -> None: @@ -24,20 +28,22 @@ async def test_datadog_setup_full(hass: HomeAssistant) -> None: config = {datadog.DOMAIN: {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, ): assert await async_setup_component(hass, datadog.DOMAIN, config) - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=123) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call("host", 123) async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, ): assert await async_setup_component( hass, @@ -51,20 +57,31 @@ async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: }, ) - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=8125) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call("host", 8125) async def test_logbook_entry(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): + mock_statsd = mock_statsd_class.return_value + assert await async_setup_component( hass, datadog.DOMAIN, - {datadog.DOMAIN: {"host": "host", "rate": datadog.DEFAULT_RATE}}, + { + datadog.DOMAIN: { + "host": "host", + "port": datadog.DEFAULT_PORT, + "rate": datadog.DEFAULT_RATE, + "prefix": datadog.DEFAULT_PREFIX, + } + }, ) event = { @@ -79,19 +96,21 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: assert mock_statsd.event.call_count == 1 assert mock_statsd.event.call_args == mock.call( title="Home Assistant", - text=f"%%% \n **{event['name']}** {event['message']} \n %%%", + message=f"%%% \n **{event['name']}** {event['message']} \n %%%", tags=["entity:sensor.foo.bar", "domain:automation"], ) - mock_statsd.event.reset_mock() - async def test_state_changed(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): + mock_statsd = mock_statsd_class.return_value + assert await async_setup_component( hass, datadog.DOMAIN, @@ -109,12 +128,7 @@ async def test_state_changed(hass: HomeAssistant) -> None: attributes = {"elevation": 3.2, "temperature": 5.0, "up": True, "down": False} for in_, out in valid.items(): - state = mock.MagicMock( - domain="sensor", - entity_id="sensor.foobar", - state=in_, - attributes=attributes, - ) + state = create_mock_state("sensor.foobar", in_, attributes) hass.states.async_set(state.entity_id, state.state, state.attributes) await hass.async_block_till_done() assert mock_statsd.gauge.call_count == 5 @@ -145,3 +159,56 @@ async def test_state_changed(hass: HomeAssistant) -> None: hass.states.async_set("domain.test", invalid, {}) await hass.async_block_till_done() assert not mock_statsd.gauge.called + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unloading the config entry cleans up properly.""" + client = mock.MagicMock() + + with ( + patch("homeassistant.components.datadog.DogStatsd", return_value=client), + patch("homeassistant.components.datadog.initialize"), + ): + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + client.flush.assert_called_once() + client.close_socket.assert_called_once() + + +async def test_state_changed_skips_unknown(hass: HomeAssistant) -> None: + """Test state_changed_listener skips None and unknown states.""" + entry = MockConfigEntry(domain=datadog.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + await async_setup_entry(hass, entry) + + # Test None state + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": None}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called + + # Test STATE_UNKNOWN + unknown_state = mock.MagicMock() + unknown_state.state = STATE_UNKNOWN + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": unknown_state}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called From f481c1b92f217008958a749f3654f21d84762651 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 24 Jul 2025 19:33:34 +0900 Subject: [PATCH 108/235] Add sensors for ventilator in LG ThinQ (#140846) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/icons.json | 15 ++++++ homeassistant/components/lg_thinq/sensor.py | 40 +++++++++++++++ .../components/lg_thinq/strings.json | 51 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 02af1dec155..303660aef75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -219,6 +219,9 @@ "total_pollution_level": { "default": "mdi:air-filter" }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, "monitoring_enabled": { "default": "mdi:monitor-eye" }, @@ -330,9 +333,21 @@ "hop_oil_info": { "default": "mdi:information-box-outline" }, + "hop_oil_capsule_1": { + "default": "mdi:information-box-outline" + }, + "hop_oil_capsule_2": { + "default": "mdi:information-box-outline" + }, "flavor_info": { "default": "mdi:information-box-outline" }, + "flavor_capsule_1": { + "default": "mdi:information-box-outline" + }, + "flavor_capsule_2": { + "default": "mdi:information-box-outline" + }, "beer_remain": { "default": "mdi:glass-mug-variant" }, diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 754b07cb2db..44dfd251dc6 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -75,6 +75,11 @@ AIR_QUALITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { device_class=SensorDeviceClass.ENUM, translation_key=ThinQProperty.TOTAL_POLLUTION_LEVEL, ), + ThinQProperty.CO2: SensorEntityDescription( + key=ThinQProperty.CO2, + device_class=SensorDeviceClass.ENUM, + translation_key="carbon_dioxide", + ), } BATTERY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.BATTERY_PERCENT: SensorEntityDescription( @@ -175,10 +180,30 @@ RECIPE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { key=ThinQProperty.HOP_OIL_INFO, translation_key=ThinQProperty.HOP_OIL_INFO, ), + ThinQProperty.HOP_OIL_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_1, + ), + ThinQProperty.HOP_OIL_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_2, + ), ThinQProperty.FLAVOR_INFO: SensorEntityDescription( key=ThinQProperty.FLAVOR_INFO, translation_key=ThinQProperty.FLAVOR_INFO, ), + ThinQProperty.FLAVOR_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_1, + ), + ThinQProperty.FLAVOR_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_2, + ), ThinQProperty.BEER_REMAIN: SensorEntityDescription( key=ThinQProperty.BEER_REMAIN, native_unit_of_measurement=PERCENTAGE, @@ -415,6 +440,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DEHUMIDIFIER: ( JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], @@ -435,7 +461,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RECIPE_SENSOR_DESC[ThinQProperty.WORT_INFO], RECIPE_SENSOR_DESC[ThinQProperty.YEAST_INFO], RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], @@ -497,6 +527,16 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE], ), + DeviceType.VENTILATOR: ( + AIR_QUALITY_SENSOR_DESC[ThinQProperty.CO2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM10], + TEMPERATURE_SENSOR_DESC[ThinQProperty.CURRENT_TEMPERATURE], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + ), DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS, DeviceType.WASHCOMBO_MINI: WASHER_SENSORS, DeviceType.WASHER: WASHER_SENSORS, diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 402816466ea..d0972a80127 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -333,6 +333,19 @@ "very_bad": "Poor" } }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "state": { + "invalid": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::invalid%]", + "good": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::good%]", + "normal": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "moderate": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "unhealthy": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "very_bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]", + "poor": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]" + } + }, "monitoring_enabled": { "name": "Air quality sensor", "state": { @@ -771,9 +784,47 @@ "hop_oil_info": { "name": "Hops" }, + "hop_oil_capsule_1": { + "name": "First hop", + "state": { + "cascade": "Cascade", + "chinook": "Chinook", + "goldings": "Goldings", + "fuggles": "Fuggles", + "hallertau": "Hallertau", + "citrussy": "Citrussy" + } + }, + "hop_oil_capsule_2": { + "name": "Second hop", + "state": { + "cascade": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::cascade%]", + "chinook": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::chinook%]", + "goldings": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::goldings%]", + "fuggles": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::fuggles%]", + "hallertau": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::hallertau%]", + "citrussy": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::citrussy%]" + } + }, "flavor_info": { "name": "Flavor" }, + "flavor_capsule_1": { + "name": "First flavor", + "state": { + "coriander": "Coriander", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "Orange" + } + }, + "flavor_capsule_2": { + "name": "Second flavor", + "state": { + "coriander": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::orange%]" + } + }, "beer_remain": { "name": "Recipe progress" }, From feeef8871030eb2701db5d9c7f2413a7e877c10a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 24 Jul 2025 12:07:35 +0100 Subject: [PATCH 109/235] Bump aiomealie to 0.10.0 (#149370) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 0aa9aa86847..804011b3d9a 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["aiomealie==0.9.6"] + "requirements": ["aiomealie==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd87da623ba..4dd2505efd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -310,7 +310,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05469693143..4b7b319ca37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -292,7 +292,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 326bcc3f053d3297fda1c8138a02ce04bbdde65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 24 Jul 2025 14:32:51 +0200 Subject: [PATCH 110/235] Update aioairzone-cloud to v0.7.0 (#149369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/conftest.py | 18 ++++++++++++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 41a823386e1..0747678c5a4 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.16"] + "requirements": ["aioairzone-cloud==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4dd2505efd5..dab44ec91a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.16 +aioairzone-cloud==0.7.0 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b7b319ca37..45b664040a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.16 +aioairzone-cloud==0.7.0 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/tests/components/airzone_cloud/conftest.py b/tests/components/airzone_cloud/conftest.py index b289efd3fb9..10388eb63d3 100644 --- a/tests/components/airzone_cloud/conftest.py +++ b/tests/components/airzone_cloud/conftest.py @@ -2,20 +2,34 @@ from unittest.mock import patch +from aioairzone_cloud.cloudapi import AirzoneCloudApi import pytest +class MockAirzoneCloudApi(AirzoneCloudApi): + """Mock AirzoneCloudApi class.""" + + async def mock_update(self: "AirzoneCloudApi"): + """Mock AirzoneCloudApi _update function.""" + await self.update_polling() + + @pytest.fixture(autouse=True) def airzone_cloud_no_websockets(): """Fixture to completely disable Airzone Cloud WebSockets.""" with ( patch( - "homeassistant.components.airzone_cloud.AirzoneCloudApi._update_websockets", - return_value=False, + "homeassistant.components.airzone_cloud.AirzoneCloudApi._update", + side_effect=MockAirzoneCloudApi.mock_update, + autospec=True, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.connect_installation_websockets", return_value=None, ), + patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_websockets", + return_value=None, + ), ): yield From fea2ef1ac17cc5a41e23da3fe9fdc3e9cd033b71 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 14:37:01 +0200 Subject: [PATCH 111/235] Bump `imgw_pib` to version 1.5.1 (#149368) --- homeassistant/components/imgw_pib/manifest.json | 2 +- homeassistant/components/imgw_pib/strings.json | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/snapshots/test_sensor.ambr | 2 ++ 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 79118d10de6..62a4f41ba1f 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.5.0"] + "requirements": ["imgw_pib==1.5.1"] } diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 7adb1673c8a..d55c134ba3b 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -25,6 +25,7 @@ "name": "Hydrological alert", "state": { "no_alert": "No alert", + "exceeding_the_warning_level": "Exceeding the warning level", "hydrological_drought": "Hydrological drought", "rapid_water_level_rise": "Rapid water level rise" }, @@ -41,6 +42,7 @@ "options": { "state": { "no_alert": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::no_alert%]", + "exceeding_the_warning_level": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::exceeding_the_warning_level%]", "hydrological_drought": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::hydrological_drought%]", "rapid_water_level_rise": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::rapid_water_level_rise%]" } diff --git a/requirements_all.txt b/requirements_all.txt index dab44ec91a8..3cecc30d6a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1237,7 +1237,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.0 +imgw_pib==1.5.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45b664040a2..07514077adc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.0 +imgw_pib==1.5.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 276ea41eecf..cdefd949560 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -9,6 +9,7 @@ 'no_alert', 'hydrological_drought', 'rapid_water_level_rise', + 'exceeding_the_warning_level', ]), }), 'config_entry_id': , @@ -51,6 +52,7 @@ 'no_alert', 'hydrological_drought', 'rapid_water_level_rise', + 'exceeding_the_warning_level', ]), 'probability': 80, 'valid_from': datetime.datetime(2024, 4, 27, 7, 0, tzinfo=datetime.timezone.utc), From dd3c9ab3afba11afab0eae5889dc793839e7ace0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Jul 2025 15:34:00 +0200 Subject: [PATCH 112/235] Use OptionsFlowWithReload in mqtt (#149092) --- homeassistant/components/mqtt/__init__.py | 11 ----------- homeassistant/components/mqtt/config_flow.py | 6 +++--- tests/components/mqtt/test_config_flow.py | 16 +++++++--------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9e3dc59f852..4f00c4da958 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -246,14 +246,6 @@ MQTT_PUBLISH_SCHEMA = vol.Schema( ) -async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle signals of config entry being updated. - - Causes for this is config entry options changing. - """ - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: """Unregister open config issues.""" @@ -435,9 +427,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data.subscriptions_to_restore ) mqtt_data.subscriptions_to_restore = set() - mqtt_data.reload_dispatchers.append( - entry.add_update_listener(_async_config_entry_updated) - ) return (mqtt_data, conf) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 52f00c82c27..023872d410c 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -52,7 +52,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.const import ( @@ -2537,7 +2537,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class MQTTOptionsFlowHandler(OptionsFlow): +class MQTTOptionsFlowHandler(OptionsFlowWithReload): """Handle MQTT options.""" async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: @@ -3353,7 +3353,7 @@ def _validate_pki_file( async def async_get_broker_settings( # noqa: C901 - flow: ConfigFlow | OptionsFlow, + flow: ConfigFlow | OptionsFlowWithReload, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, user_input: dict[str, Any] | None, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ce0a0c44a79..b45a4a66aa9 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -17,7 +17,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError -from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.config_flow import ( + PWD_NOT_CHANGED, + MQTTOptionsFlowHandler, +) from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( @@ -193,8 +196,8 @@ def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, Magi @pytest.fixture def mock_reload_after_entry_update() -> Generator[MagicMock]: """Mock out the reload after updating the entry.""" - with patch( - "homeassistant.components.mqtt._async_config_entry_updated" + with patch.object( + MQTTOptionsFlowHandler, "automatic_reload", return_value=False ) as mock_reload: yield mock_reload @@ -1330,11 +1333,11 @@ async def test_keepalive_validation( assert result["reason"] == "reconfigure_successful" +@pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_disable_birth_will( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, - mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" await mqtt_mock_entry() @@ -1348,7 +1351,6 @@ async def test_disable_birth_will( }, ) await hass.async_block_till_done() - mock_reload_after_entry_update.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1387,10 +1389,6 @@ async def test_disable_birth_will( mqtt.CONF_WILL_MESSAGE: {}, } - await hass.async_block_till_done() - # assert that the entry was reloaded with the new config - assert mock_reload_after_entry_update.call_count == 1 - async def test_invalid_discovery_prefix( hass: HomeAssistant, From d6175fb3835b449f58b3dfaf0b9a3b8da6993632 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:05:24 +0200 Subject: [PATCH 113/235] Update mypy-dev to 1.18.0a3 (#149383) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b0affc56113..cc9eff9dc3f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.18.0a2 +mypy-dev==1.18.0a3 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From a0992498c6427ac0b12b4ac64a006b0d19551cc2 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:52:43 +0200 Subject: [PATCH 114/235] Improve removal of stale entities/devices in Husqvarna Automower (#148428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../husqvarna_automower/coordinator.py | 209 ++++++++---------- 1 file changed, 98 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 7fc1e628e27..91adc8c75ec 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -58,9 +58,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] - self._devices_last_update: set[str] = set() - self._zones_last_update: dict[str, set[str]] = {} - self._areas_last_update: dict[str, set[int]] = {} @override @callback @@ -87,11 +84,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): """Handle data updates and process dynamic entity management.""" if self.data is not None: self._async_add_remove_devices() - for mower_id in self.data: - if self.data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones() - if self.data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas() + if any( + mower_data.capabilities.stay_out_zones + for mower_data in self.data.values() + ): + self._async_add_remove_stay_out_zones() + if any( + mower_data.capabilities.work_areas for mower_data in self.data.values() + ): + self._async_add_remove_work_areas() @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: @@ -161,44 +162,36 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): ) def _async_add_remove_devices(self) -> None: - """Add new device, remove non-existing device.""" + """Add new devices and remove orphaned devices from the registry.""" current_devices = set(self.data) - - # Skip update if no changes - if current_devices == self._devices_last_update: - return - - # Process removed devices - removed_devices = self._devices_last_update - current_devices - if removed_devices: - _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices))) - self._remove_device(removed_devices) - - # Process new device - new_devices = current_devices - self._devices_last_update - if new_devices: - _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) - self._add_new_devices(new_devices) - - # Update device state - self._devices_last_update = current_devices - - def _remove_device(self, removed_devices: set[str]) -> None: - """Remove device from the registry.""" device_registry = dr.async_get(self.hass) - for mower_id in removed_devices: - if device := device_registry.async_get_device( - identifiers={(DOMAIN, str(mower_id))} - ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - def _add_new_devices(self, new_devices: set[str]) -> None: - """Add new device and trigger callbacks.""" - for mower_callback in self.new_devices_callbacks: - mower_callback(new_devices) + registered_devices: set[str] = { + str(mower_id) + for device in device_registry.devices.get_devices_for_config_entry_id( + self.config_entry.entry_id + ) + for domain, mower_id in device.identifiers + if domain == DOMAIN + } + + orphaned_devices = registered_devices - current_devices + if orphaned_devices: + _LOGGER.debug("Removing orphaned devices: %s", orphaned_devices) + device_registry = dr.async_get(self.hass) + for mower_id in orphaned_devices: + dev = device_registry.async_get_device(identifiers={(DOMAIN, mower_id)}) + if dev is not None: + device_registry.async_update_device( + device_id=dev.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + new_devices = current_devices - registered_devices + if new_devices: + _LOGGER.debug("New devices found: %s", new_devices) + for mower_callback in self.new_devices_callbacks: + mower_callback(new_devices) def _async_add_remove_stay_out_zones(self) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" @@ -209,42 +202,39 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): and mower_data.stay_out_zones is not None } - if not self._zones_last_update: - self._zones_last_update = current_zones - return - - if current_zones == self._zones_last_update: - return - - self._zones_last_update = self._update_stay_out_zones(current_zones) - - def _update_stay_out_zones( - self, current_zones: dict[str, set[str]] - ) -> dict[str, set[str]]: - """Update stay-out zones by adding and removing as needed.""" - new_zones = { - mower_id: zones - self._zones_last_update.get(mower_id, set()) - for mower_id, zones in current_zones.items() - } - removed_zones = { - mower_id: self._zones_last_update.get(mower_id, set()) - zones - for mower_id, zones in current_zones.items() - } - - for mower_id, zones in new_zones.items(): - for zone_callback in self.new_zones_callbacks: - zone_callback(mower_id, set(zones)) - entity_registry = er.async_get(self.hass) - for mower_id, zones in removed_zones.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for zone in zones: - if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"): - entity_registry.async_remove(entity_entry.entity_id) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - return current_zones + registered_zones: dict[str, set[str]] = {} + for mower_id in self.data: + registered_zones[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_stay_out_zones"): + zone_id = uid.removeprefix(f"{mower_id}_").removesuffix( + "_stay_out_zones" + ) + registered_zones[mower_id].add(zone_id) + + for mower_id, current_ids in current_zones.items(): + known_ids = registered_zones.get(mower_id, set()) + + new_zones = current_ids - known_ids + removed_zones = known_ids - current_ids + + if new_zones: + _LOGGER.debug("New stay-out zones: %s", new_zones) + for zone_callback in self.new_zones_callbacks: + zone_callback(mower_id, new_zones) + + if removed_zones: + _LOGGER.debug("Removing stay-out zones: %s", removed_zones) + for entry in entries: + for zone_id in removed_zones: + if entry.unique_id == f"{mower_id}_{zone_id}_stay_out_zones": + entity_registry.async_remove(entry.entity_id) def _async_add_remove_work_areas(self) -> None: """Add new work areas, remove non-existing work areas.""" @@ -254,39 +244,36 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): if mower_data.capabilities.work_areas and mower_data.work_areas is not None } - if not self._areas_last_update: - self._areas_last_update = current_areas - return - - if current_areas == self._areas_last_update: - return - - self._areas_last_update = self._update_work_areas(current_areas) - - def _update_work_areas( - self, current_areas: dict[str, set[int]] - ) -> dict[str, set[int]]: - """Update work areas by adding and removing as needed.""" - new_areas = { - mower_id: areas - self._areas_last_update.get(mower_id, set()) - for mower_id, areas in current_areas.items() - } - removed_areas = { - mower_id: self._areas_last_update.get(mower_id, set()) - areas - for mower_id, areas in current_areas.items() - } - - for mower_id, areas in new_areas.items(): - for area_callback in self.new_areas_callbacks: - area_callback(mower_id, set(areas)) - entity_registry = er.async_get(self.hass) - for mower_id, areas in removed_areas.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for area in areas: - if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"): - entity_registry.async_remove(entity_entry.entity_id) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - return current_areas + registered_areas: dict[str, set[int]] = {} + for mower_id in self.data: + registered_areas[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_work_area"): + parts = uid.removeprefix(f"{mower_id}_").split("_") + area_id_str = parts[0] if parts else None + if area_id_str and area_id_str.isdigit(): + registered_areas[mower_id].add(int(area_id_str)) + + for mower_id, current_ids in current_areas.items(): + known_ids = registered_areas.get(mower_id, set()) + + new_areas = current_ids - known_ids + removed_areas = known_ids - current_ids + + if new_areas: + _LOGGER.debug("New work areas: %s", new_areas) + for area_callback in self.new_areas_callbacks: + area_callback(mower_id, new_areas) + + if removed_areas: + _LOGGER.debug("Removing work areas: %s", removed_areas) + for entry in entries: + for area_id in removed_areas: + if entry.unique_id.startswith(f"{mower_id}_{area_id}_"): + entity_registry.async_remove(entry.entity_id) From 6adcd3452151d77b2359f612431ca9117c1ee3cc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 17:10:46 +0200 Subject: [PATCH 115/235] Remove space character from "autodetect" in `xiaomi_miio` (#149381) --- homeassistant/components/xiaomi_miio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index fef185daf41..00e11224649 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -311,7 +311,7 @@ "name": "Learn mode" }, "auto_detect": { - "name": "Auto detect" + "name": "Autodetect" }, "ionizer": { "name": "Ionizer" From 760b69d458b6b162c67d9665b28ebdbc18334152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 24 Jul 2025 18:13:54 +0300 Subject: [PATCH 116/235] Only send integers when setting Huum sauna temperature (#149380) --- homeassistant/components/huum/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 6a50137f0a7..af4e8cc3623 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -89,7 +89,10 @@ class HuumDevice(HuumBaseEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" if hvac_mode == HVACMode.HEAT: - await self._turn_on(self.target_temperature) + # Make sure to send integers + # The temperature is not always an integer if the user uses Fahrenheit + temperature = int(self.target_temperature) + await self._turn_on(temperature) elif hvac_mode == HVACMode.OFF: await self.coordinator.huum.turn_off() await self.coordinator.async_refresh() @@ -99,6 +102,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None or self.hvac_mode != HVACMode.HEAT: return + temperature = int(temperature) await self._turn_on(temperature) await self.coordinator.async_refresh() From 8b8616182dc4bab268bde63968463d7136c29621 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 24 Jul 2025 17:27:02 +0200 Subject: [PATCH 117/235] Allow downloading a device analytics dump (#149376) --- .../components/analytics/__init__.py | 3 + .../components/analytics/analytics.py | 89 +++++++++++++- homeassistant/components/analytics/http.py | 27 ++++ .../components/analytics/manifest.json | 2 +- tests/components/analytics/test_analytics.py | 116 +++++++++++++++++- 5 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/analytics/http.py diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 0df3b8138e2..83610f0dc75 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +from .http import AnalyticsDevicesView CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -55,6 +56,8 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_analytics) websocket_api.async_register_command(hass, websocket_analytics_preferences) + hass.http.register_view(AnalyticsDevicesView) + hass.data[DATA_COMPONENT] = analytics return True diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 1a07a8abd0f..8a2a182c796 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -27,7 +27,7 @@ from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.storage import Store @@ -77,6 +77,11 @@ from .const import ( ) +def gen_uuid() -> str: + """Generate a new UUID.""" + return uuid.uuid4().hex + + @dataclass class AnalyticsData: """Analytics data.""" @@ -184,7 +189,7 @@ class Analytics: return if self._data.uuid is None: - self._data.uuid = uuid.uuid4().hex + self._data.uuid = gen_uuid() await self._store.async_save(dataclass_asdict(self._data)) if self.supervisor: @@ -381,3 +386,83 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: ).values(): domains.update(platforms) return domains + + +async def async_devices_payload(hass: HomeAssistant) -> dict: + """Return the devices payload.""" + integrations_without_model_id: set[str] = set() + devices: list[dict[str, Any]] = [] + dev_reg = dr.async_get(hass) + # Devices that need via device info set + new_indexes: dict[str, int] = {} + via_devices: dict[str, str] = {} + + seen_integrations = set() + + for device in dev_reg.devices.values(): + # Ignore services + if device.entry_type: + continue + + if not device.primary_config_entry: + continue + + config_entry = hass.config_entries.async_get_entry(device.primary_config_entry) + + if config_entry is None: + continue + + seen_integrations.add(config_entry.domain) + + if not device.model_id: + integrations_without_model_id.add(config_entry.domain) + continue + + if not device.manufacturer: + continue + + new_indexes[device.id] = len(devices) + devices.append( + { + "integration": config_entry.domain, + "manufacturer": device.manufacturer, + "model_id": device.model_id, + "model": device.model, + "sw_version": device.sw_version, + "hw_version": device.hw_version, + "has_suggested_area": device.suggested_area is not None, + "has_configuration_url": device.configuration_url is not None, + "via_device": None, + } + ) + if device.via_device_id: + via_devices[device.id] = device.via_device_id + + for from_device, via_device in via_devices.items(): + if via_device not in new_indexes: + continue + devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device] + + integrations = { + domain: integration + for domain, integration in ( + await async_get_integrations(hass, seen_integrations) + ).items() + if isinstance(integration, Integration) + } + + for device_info in devices: + if integration := integrations.get(device_info["integration"]): + device_info["is_custom_integration"] = not integration.is_built_in + + return { + "version": "home-assistant:1", + "no_model_id": sorted( + [ + domain + for domain in integrations_without_model_id + if domain in integrations and integrations[domain].is_built_in + ] + ), + "devices": devices, + } diff --git a/homeassistant/components/analytics/http.py b/homeassistant/components/analytics/http.py new file mode 100644 index 00000000000..a91b373bc45 --- /dev/null +++ b/homeassistant/components/analytics/http.py @@ -0,0 +1,27 @@ +"""HTTP endpoints for analytics integration.""" + +from aiohttp import web + +from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin +from homeassistant.core import HomeAssistant + +from .analytics import async_devices_payload + + +class AnalyticsDevicesView(HomeAssistantView): + """View to handle analytics devices payload download requests.""" + + url = "/api/analytics/devices" + name = "api:analytics:devices" + + @require_admin + async def get(self, request: web.Request) -> web.Response: + """Return analytics devices payload as JSON.""" + hass: HomeAssistant = request.app[KEY_HASS] + payload = await async_devices_payload(hass) + return self.json( + payload, + headers={ + "Content-Disposition": "attachment; filename=analytics_devices.json" + }, + ) diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 5142a86ad97..ab51ed31c9e 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -3,7 +3,7 @@ "name": "Analytics", "after_dependencies": ["energy", "hassio", "recorder"], "codeowners": ["@home-assistant/core", "@ludeeus"], - "dependencies": ["api", "websocket_api"], + "dependencies": ["api", "websocket_api", "http"], "documentation": "https://www.home-assistant.io/integrations/analytics", "integration_type": "system", "iot_class": "cloud_push", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 01d08572197..90f3049d8fd 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1,8 +1,9 @@ """The tests for the analytics .""" from collections.abc import Generator +from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, patch import aiohttp from awesomeversion import AwesomeVersion @@ -10,7 +11,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type -from homeassistant.components.analytics.analytics import Analytics +from homeassistant.components.analytics.analytics import ( + Analytics, + async_devices_payload, +) from homeassistant.components.analytics.const import ( ANALYTICS_ENDPOINT_URL, ANALYTICS_ENDPOINT_URL_DEV, @@ -22,11 +26,13 @@ from homeassistant.components.analytics.const import ( from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator MOCK_UUID = "abcdefg" MOCK_VERSION = "1970.1.0" @@ -37,8 +43,9 @@ MOCK_VERSION_NIGHTLY = "1970.1.0.dev19700101" @pytest.fixture(autouse=True) def uuid_mock() -> Generator[None]: """Mock the UUID.""" - with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: - hex_mock.return_value = MOCK_UUID + with patch( + "homeassistant.components.analytics.analytics.gen_uuid", return_value=MOCK_UUID + ): yield @@ -966,3 +973,104 @@ async def test_submitting_legacy_integrations( assert submitted_data["integrations"] == ["legacy_binary_sensor"] assert submitted_data == logged_data assert snapshot == submitted_data + + +async def test_devices_payload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test devices payload.""" + assert await async_setup_component(hass, "analytics", {}) + assert await async_devices_payload(hass) == { + "version": "home-assistant:1", + "no_model_id": [], + "devices": [], + } + + mock_config_entry = MockConfigEntry(domain="hue") + mock_config_entry.add_to_hass(hass) + + # Normal entry + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "1")}, + sw_version="test-sw-version", + hw_version="test-hw-version", + name="test-name", + manufacturer="test-manufacturer", + model="test-model", + model_id="test-model-id", + suggested_area="Game Room", + configuration_url="http://example.com/config", + ) + + # Ignored because service type + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "2")}, + manufacturer="test-manufacturer", + model_id="test-model-id", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + # Ignored because no model id + no_model_id_config_entry = MockConfigEntry(domain="no_model_id") + no_model_id_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=no_model_id_config_entry.entry_id, + identifiers={("device", "4")}, + manufacturer="test-manufacturer", + ) + + # Ignored because no manufacturer + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "5")}, + model_id="test-model-id", + ) + + # Entry with via device + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "6")}, + manufacturer="test-manufacturer6", + model_id="test-model-id6", + via_device=("device", "1"), + ) + + assert await async_devices_payload(hass) == { + "version": "home-assistant:1", + "no_model_id": [], + "devices": [ + { + "manufacturer": "test-manufacturer", + "model_id": "test-model-id", + "model": "test-model", + "sw_version": "test-sw-version", + "hw_version": "test-hw-version", + "integration": "hue", + "is_custom_integration": False, + "has_suggested_area": True, + "has_configuration_url": True, + "via_device": None, + }, + { + "manufacturer": "test-manufacturer6", + "model_id": "test-model-id6", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_suggested_area": False, + "has_configuration_url": False, + "via_device": 0, + }, + ], + } + + client = await hass_client() + response = await client.get("/api/analytics/devices") + assert response.status == HTTPStatus.OK + assert await response.json() == await async_devices_payload(hass) From ef7cd815b287180c18544ec554233dc033ca2d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 24 Jul 2025 16:52:12 +0100 Subject: [PATCH 118/235] Add list of targeted entities to target state event (#149203) --- homeassistant/helpers/target.py | 18 +++++++++++++----- tests/helpers/test_target.py | 12 +++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 239d1e66336..0b902ea4d23 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -40,6 +40,14 @@ from .typing import ConfigType _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass(slots=True, frozen=True) +class TargetStateChangedData: + """Data for state change events related to targets.""" + + state_change_event: Event[EventStateChangedData] + targeted_entity_ids: set[str] + + def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: """Check if ids can match anything.""" return ids not in (None, ENTITY_MATCH_NONE) @@ -259,7 +267,7 @@ class TargetStateChangeTracker: self, hass: HomeAssistant, selector_data: TargetSelectorData, - action: Callable[[Event[EventStateChangedData]], Any], + action: Callable[[TargetStateChangedData], Any], ) -> None: """Initialize the state change tracker.""" self._hass = hass @@ -281,6 +289,8 @@ class TargetStateChangeTracker: self._hass, self._selector_data, expand_group=False ) + tracked_entities = selected.referenced.union(selected.indirectly_referenced) + @callback def state_change_listener(event: Event[EventStateChangedData]) -> None: """Handle state change events.""" @@ -288,9 +298,7 @@ class TargetStateChangeTracker: event.data["entity_id"] in selected.referenced or event.data["entity_id"] in selected.indirectly_referenced ): - self._action(event) - - tracked_entities = selected.referenced.union(selected.indirectly_referenced) + self._action(TargetStateChangedData(event, tracked_entities)) _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) self._state_change_unsub = async_track_state_change_event( @@ -339,7 +347,7 @@ class TargetStateChangeTracker: def async_track_target_selector_state_change_event( hass: HomeAssistant, target_selector_config: ConfigType, - action: Callable[[Event[EventStateChangedData]], Any], + action: Callable[[TargetStateChangedData], Any], ) -> CALLBACK_TYPE: """Track state changes for entities referenced directly or indirectly in a target selector.""" selector_data = TargetSelectorData(target_selector_config) diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index c87a320e378..fa31ef375fd 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -14,7 +14,7 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, @@ -482,10 +482,10 @@ async def test_async_track_target_selector_state_change_event( hass: HomeAssistant, ) -> None: """Test async_track_target_selector_state_change_event with multiple targets.""" - events: list[Event[EventStateChangedData]] = [] + events: list[target.TargetStateChangedData] = [] @callback - def state_change_callback(event: Event[EventStateChangedData]): + def state_change_callback(event: target.TargetStateChangedData): """Handle state change events.""" events.append(event) @@ -504,8 +504,10 @@ async def test_async_track_target_selector_state_change_event( assert len(events) == len(entities_to_assert_change) entities_seen = set() for event in events: - entities_seen.add(event.data["entity_id"]) - assert event.data["new_state"].state == last_state + state_change_event = event.state_change_event + entities_seen.add(state_change_event.data["entity_id"]) + assert state_change_event.data["new_state"].state == last_state + assert event.targeted_entity_ids == set(entities_to_assert_change) assert entities_seen == set(entities_to_assert_change) events.clear() From 995a99e25640a0fe6efddb91cdf22ea9dbec626a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 24 Jul 2025 18:54:00 +0300 Subject: [PATCH 119/235] Bump aioamazondevices to 3.5.1 (#149385) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 9a98be052be..74187ba7ed4 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "silver", - "requirements": ["aioamazondevices==3.5.0"] + "requirements": ["aioamazondevices==3.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3cecc30d6a8..9c0bb3df3a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.7.0 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.0 +aioamazondevices==3.5.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07514077adc..b028a880bfd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.7.0 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.0 +aioamazondevices==3.5.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 56d97f5545346a6b4ed146d5977e246d3900e12d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 18:49:46 +0200 Subject: [PATCH 120/235] Drop duplicated lower-case "qnap" from setup description (#149384) --- homeassistant/components/qnap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 0d82443da11..1979be3e827 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to the QNAP device", - "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", + "description": "This sensor allows getting various statistics from your QNAP NAS.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", From eeca5a80302875378d778a8b417307f0d0ac868e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20=27Horm=27=20Hor=C3=A1k?= Date: Thu, 24 Jul 2025 19:10:01 +0200 Subject: [PATCH 121/235] Improve Airthings test coverage (#144750) Co-authored-by: Joostlek --- .../components/airthings/config_flow.py | 5 +- homeassistant/components/airthings/sensor.py | 4 +- tests/components/airthings/__init__.py | 11 + tests/components/airthings/conftest.py | 79 + .../airthings/fixtures/device_view_plus.json | 19 + .../fixtures/device_wave_enhance.json | 18 + .../airthings/fixtures/device_wave_plus.json | 17 + .../airthings/snapshots/test_sensor.ambr | 1352 +++++++++++++++++ .../components/airthings/test_config_flow.py | 166 +- tests/components/airthings/test_sensor.py | 23 + 10 files changed, 1591 insertions(+), 103 deletions(-) create mode 100644 tests/components/airthings/conftest.py create mode 100644 tests/components/airthings/fixtures/device_view_plus.json create mode 100644 tests/components/airthings/fixtures/device_wave_enhance.json create mode 100644 tests/components/airthings/fixtures/device_wave_plus.json create mode 100644 tests/components/airthings/snapshots/test_sensor.ambr create mode 100644 tests/components/airthings/test_sensor.py diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index ab453ede20c..23711b7a9a2 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -45,6 +45,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): ) errors = {} + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() try: await airthings.get_token( @@ -60,9 +62,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_ID]) - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Airthings", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index ff30fb2f2ae..45e532268c0 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -150,7 +150,7 @@ async def async_setup_entry( coordinator = entry.runtime_data entities = [ - AirthingsHeaterEnergySensor( + AirthingsDeviceSensor( coordinator, airthings_device, SENSORS[sensor_types], @@ -162,7 +162,7 @@ async def async_setup_entry( async_add_entities(entities) -class AirthingsHeaterEnergySensor( +class AirthingsDeviceSensor( CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity ): """Representation of a Airthings Sensor device.""" diff --git a/tests/components/airthings/__init__.py b/tests/components/airthings/__init__.py index e331fb2f2c6..0d2c58c22ae 100644 --- a/tests/components/airthings/__init__.py +++ b/tests/components/airthings/__init__.py @@ -1 +1,12 @@ """Tests for the Airthings integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airthings/conftest.py b/tests/components/airthings/conftest.py new file mode 100644 index 00000000000..4c67e35108c --- /dev/null +++ b/tests/components/airthings/conftest.py @@ -0,0 +1,79 @@ +"""Airthings test configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from airthings import Airthings, AirthingsDevice +import pytest + +from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.const import CONF_ID + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ID: "client_id", + CONF_SECRET: "secret", + }, + unique_id="client_id", + ) + + +@pytest.fixture(params=["view_plus", "wave_plus", "wave_enhance"]) +def airthings_fixture( + request: pytest.FixtureRequest, +) -> str: + """Return the fixture name for Airthings device types.""" + return request.param + + +@pytest.fixture +def mock_airthings_device(airthings_fixture: str) -> AirthingsDevice: + """Mock an Airthings device.""" + return AirthingsDevice( + **load_json_object_fixture(f"device_{airthings_fixture}.json", DOMAIN) + ) + + +@pytest.fixture +def mock_airthings_client( + mock_airthings_device: AirthingsDevice, mock_airthings_token: AsyncMock +) -> Generator[Airthings]: + """Mock an Airthings client.""" + with patch( + "homeassistant.components.airthings.Airthings", + autospec=True, + ) as mock_airthings: + client = mock_airthings.return_value + client.update_devices.return_value = { + mock_airthings_device.device_id: mock_airthings_device + } + yield client + + +@pytest.fixture +def mock_airthings_token() -> Generator[Airthings]: + """Mock an Airthings client.""" + with ( + patch( + "homeassistant.components.airthings.config_flow.airthings.get_token", + return_value="test_token", + ) as mock_get_token, + ): + yield mock_get_token + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airthings/fixtures/device_view_plus.json b/tests/components/airthings/fixtures/device_view_plus.json new file mode 100644 index 00000000000..194b0493d2e --- /dev/null +++ b/tests/components/airthings/fixtures/device_view_plus.json @@ -0,0 +1,19 @@ +{ + "device_id": "2960000001", + "name": "Living Room", + "is_active": true, + "device_type": "VIEW_PLUS", + "product_name": "View Plus", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "pm1": 4.4, + "pm25": 5.5, + "pressure": 6.6, + "radonShortTermAvg": 7.7, + "temp": 8.8, + "voc": 9.9 + } +} diff --git a/tests/components/airthings/fixtures/device_wave_enhance.json b/tests/components/airthings/fixtures/device_wave_enhance.json new file mode 100644 index 00000000000..06c7c489ad1 --- /dev/null +++ b/tests/components/airthings/fixtures/device_wave_enhance.json @@ -0,0 +1,18 @@ +{ + "device_id": "3210000003", + "name": "Bedroom", + "is_active": true, + "device_type": "WAVE_ENHANCE", + "product_name": "Wave Enhance", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "lux": 4.4, + "pressure": 5.5, + "sla": 6.6, + "temp": 7.7, + "voc": 8.8 + } +} diff --git a/tests/components/airthings/fixtures/device_wave_plus.json b/tests/components/airthings/fixtures/device_wave_plus.json new file mode 100644 index 00000000000..0acf09daa62 --- /dev/null +++ b/tests/components/airthings/fixtures/device_wave_plus.json @@ -0,0 +1,17 @@ +{ + "device_id": "2930000002", + "name": "Office", + "is_active": true, + "device_type": "WAVE_PLUS", + "product_name": "Wave Plus", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "pressure": 4.4, + "radonShortTermAvg": 5.5, + "temp": 6.6, + "voc": 7.7 + } +} diff --git a/tests/components/airthings/snapshots/test_sensor.ambr b/tests/components/airthings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..67a210ca037 --- /dev/null +++ b/tests/components/airthings/snapshots/test_sensor.ambr @@ -0,0 +1,1352 @@ +# serializer version: 1 +# name: test_all_device_types[view_plus][sensor.living_room_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Living Room Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_room_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Living Room Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.living_room_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Living Room Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pm1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Living Room PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pm25', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Living Room PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_radon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_radon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon', + 'unique_id': '2960000001_radonShortTermAvg', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_radon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Radon', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_radon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.8', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_volatile_organic_compounds_parts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Living Room Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.living_room_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.9', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Bedroom Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bedroom_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bedroom Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Bedroom Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.bedroom_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Bedroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_lux', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Bedroom Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.bedroom_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_sound_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_sound_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sound pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_sla', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_sound_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': 'Bedroom Sound pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_sound_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_volatile_organic_compounds_parts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Bedroom Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.bedroom_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.8', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_atmospheric_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Office Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.office_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Office Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Office Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.office_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Office Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_radon-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_radon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon', + 'unique_id': '2930000002_radonShortTermAvg', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_radon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Radon', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.office_radon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_volatile_organic_compounds_parts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Office Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.office_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index ac42eddf769..f8791df0c26 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -1,12 +1,12 @@ """Test the Airthings config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import airthings import pytest -from homeassistant import config_entries from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -38,108 +38,87 @@ DHCP_SERVICE_INFO = [ ] -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_flow( + hass: HomeAssistant, mock_airthings_token: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we get the full flow working.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with ( - patch( - "airthings.get_token", - return_value="test_token", - ), - patch( - "homeassistant.components.airthings.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings" assert result["data"] == TEST_DATA + assert result["result"].unique_id == "client_id" assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("exception", "error"), + [ + (airthings.AirthingsAuthError, "invalid_auth"), + (airthings.AirthingsConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_airthings_token: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle exceptions correctly.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "airthings.get_token", - side_effect=airthings.AirthingsAuthError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) + mock_airthings_token.side_effect = exception - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, ) - with patch( - "airthings.get_token", - side_effect=airthings.AirthingsConnectionError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} + mock_airthings_token.side_effect = None -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, ) - with patch( - "airthings.get_token", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_flow_entry_already_exists( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test user input for config_entry that already exists.""" + mock_config_entry.add_to_hass(hass) - first_entry = MockConfigEntry( - domain="airthings", - data=TEST_DATA, - unique_id=TEST_DATA[CONF_ID], + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - first_entry.add_to_hass(hass) - with patch("airthings.get_token", return_value="token"): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA - ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -147,54 +126,45 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: @pytest.mark.parametrize("dhcp_service_info", DHCP_SERVICE_INFO) async def test_dhcp_flow( - hass: HomeAssistant, dhcp_service_info: DhcpServiceInfo + hass: HomeAssistant, + dhcp_service_info: DhcpServiceInfo, + mock_airthings_token: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: """Test the DHCP discovery flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, + context={"source": SOURCE_DHCP}, data=dhcp_service_info, - context={"source": config_entries.SOURCE_DHCP}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.airthings.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "airthings.get_token", - return_value="test_token", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings" assert result["data"] == TEST_DATA + assert result["result"].unique_id == TEST_DATA[CONF_ID] assert len(mock_setup_entry.mock_calls) == 1 -async def test_dhcp_flow_hub_already_configured(hass: HomeAssistant) -> None: +async def test_dhcp_flow_hub_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that DHCP discovery fails when already configured.""" - first_entry = MockConfigEntry( - domain="airthings", - data=TEST_DATA, - unique_id=TEST_DATA[CONF_ID], - ) - first_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, + context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO[0], - context={"source": config_entries.SOURCE_DHCP}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/airthings/test_sensor.py b/tests/components/airthings/test_sensor.py new file mode 100644 index 00000000000..d78d3356244 --- /dev/null +++ b/tests/components/airthings/test_sensor.py @@ -0,0 +1,23 @@ +"""Test the Airthings sensors.""" + +from airthings import Airthings +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_device_types( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_airthings_client: Airthings, + entity_registry: er.EntityRegistry, +) -> None: + """Test all device types.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From f2c995cf86acf0221fe93cd997372f4c8be3abc1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 19:20:28 +0200 Subject: [PATCH 122/235] Fix sentence-casing of "DSMR options" string (#149392) --- homeassistant/components/dsmr/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index e95e9ae870a..7fbfcd573ed 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -222,7 +222,7 @@ "data": { "time_between_update": "Minimum time between entity updates [s]" }, - "title": "DSMR Options" + "title": "DSMR options" } } } From 36a98470cc12869e074b71f955e81df5d84e3bbb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 19:20:42 +0200 Subject: [PATCH 123/235] Remove excessive comma from `dsmr_reader` issue description (#149393) --- homeassistant/components/dsmr_reader/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index d405898a393..6f8bcde12f4 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -263,7 +263,7 @@ "issues": { "cannot_subscribe_mqtt_topic": { "title": "Cannot subscribe to MQTT topic {topic_title}", - "description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running, before starting this integration." + "description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running before starting this integration." } } } From 5c7913c3bdeea507b15928fa4abbdac6d9789700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 24 Jul 2025 19:07:57 +0100 Subject: [PATCH 124/235] Remove door state from Whirlpool machine state sensor (#144078) --- homeassistant/components/whirlpool/sensor.py | 9 ----- .../components/whirlpool/strings.json | 6 ++-- .../whirlpool/snapshots/test_sensor.ambr | 4 --- tests/components/whirlpool/test_sensor.py | 33 ------------------- 4 files changed, 2 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 164e1b6e5fe..1bb825cc18f 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -86,15 +86,11 @@ STATE_CYCLE_SENSING = "cycle_sensing" STATE_CYCLE_SOAKING = "cycle_soaking" STATE_CYCLE_SPINNING = "cycle_spinning" STATE_CYCLE_WASHING = "cycle_washing" -STATE_DOOR_OPEN = "door_open" def washer_state(washer: Washer) -> str | None: """Determine correct states for a washer.""" - if washer.get_door_open(): - return STATE_DOOR_OPEN - machine_state = washer.get_machine_state() if machine_state == WasherMachineState.RunningMainCycle: @@ -117,9 +113,6 @@ def washer_state(washer: Washer) -> str | None: def dryer_state(dryer: Dryer) -> str | None: """Determine correct states for a dryer.""" - if dryer.get_door_open(): - return STATE_DOOR_OPEN - machine_state = dryer.get_machine_state() if machine_state == DryerMachineState.RunningMainCycle: @@ -144,13 +137,11 @@ WASHER_STATE_OPTIONS = [ STATE_CYCLE_SOAKING, STATE_CYCLE_SPINNING, STATE_CYCLE_WASHING, - STATE_DOOR_OPEN, ] DRYER_STATE_OPTIONS = [ *DRYER_MACHINE_STATE.values(), STATE_CYCLE_SENSING, - STATE_DOOR_OPEN, ] WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 27e5ebe3ea9..9f214bf204f 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -74,8 +74,7 @@ "cycle_sensing": "Cycle sensing", "cycle_soaking": "Cycle soaking", "cycle_spinning": "Cycle spinning", - "cycle_washing": "Cycle washing", - "door_open": "Door open" + "cycle_washing": "Cycle washing" } }, "dryer_state": { @@ -105,8 +104,7 @@ "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]", "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]", "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]", - "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]", - "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]" + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]" } }, "whirlpool_tank": { diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index fa67b5ecc05..64b513abe4e 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -77,7 +77,6 @@ 'system_initialize', 'cancelled', 'cycle_sensing', - 'door_open', ]), }), 'config_entry_id': , @@ -136,7 +135,6 @@ 'system_initialize', 'cancelled', 'cycle_sensing', - 'door_open', ]), }), 'context': , @@ -293,7 +291,6 @@ 'cycle_soaking', 'cycle_spinning', 'cycle_washing', - 'door_open', ]), }), 'config_entry_id': , @@ -356,7 +353,6 @@ 'cycle_soaking', 'cycle_spinning', 'cycle_washing', - 'door_open', ]), }), 'context': , diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index eaed27c95f8..85f0940fc4e 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -296,39 +296,6 @@ async def test_washer_running_states( assert state.state == expected_state -@pytest.mark.parametrize( - ("entity_id", "mock_fixture"), - [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), - ], -) -async def test_washer_dryer_door_open_state( - hass: HomeAssistant, - entity_id: str, - mock_fixture: str, - request: pytest.FixtureRequest, -) -> None: - """Test Washer/Dryer machine state when door is open.""" - mock_instance = request.getfixturevalue(mock_fixture) - await init_integration(hass) - - state = hass.states.get(entity_id) - assert state.state == "running_maincycle" - - mock_instance.get_door_open.return_value = True - - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) - assert state.state == "door_open" - - mock_instance.get_door_open.return_value = False - - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) - assert state.state == "running_maincycle" - - @pytest.mark.parametrize( ("entity_id", "mock_fixture", "mock_method_name", "values"), [ From 5c4862ffe15541b5a6b36969891e323bc79ff28f Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Fri, 25 Jul 2025 03:12:41 +0900 Subject: [PATCH 125/235] Fix Air Conditioner set temperature error in LG ThinQ (#147008) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 111 ++++++++---------- .../lg_thinq/snapshots/test_climate.ambr | 4 +- 2 files changed, 52 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 98a86a8d355..4810336c6e0 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + PRESET_NONE, SWING_OFF, SWING_ON, ClimateEntity, @@ -22,7 +23,6 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.temperature import display_temp from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -109,11 +109,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) self._attr_hvac_modes = [HVACMode.OFF] self._attr_hvac_mode = HVACMode.OFF - self._attr_preset_modes = [] + self._attr_preset_modes = [PRESET_NONE] + self._attr_preset_mode = PRESET_NONE self._attr_temperature_unit = ( self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS ) - self._requested_hvac_mode: str | None = None # Set up HVAC modes. for mode in self.data.hvac_modes: @@ -157,17 +157,19 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) if self.data.is_on: - hvac_mode = self._requested_hvac_mode or self.data.hvac_mode + hvac_mode = self.data.hvac_mode if hvac_mode in STR_TO_HVAC: self._attr_hvac_mode = STR_TO_HVAC.get(hvac_mode) - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE elif hvac_mode in THINQ_PRESET_MODE: + self._attr_hvac_mode = ( + HVACMode.COOL if hvac_mode == "energy_saving" else HVACMode.FAN_ONLY + ) self._attr_preset_mode = hvac_mode else: self._attr_hvac_mode = HVACMode.OFF - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE - self.reset_requested_hvac_mode() self._attr_current_humidity = self.data.humidity self._attr_current_temperature = self.data.current_temp @@ -202,10 +204,6 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.target_temperature_step, ) - def reset_requested_hvac_mode(self) -> None: - """Cancel request to set hvac mode.""" - self._requested_hvac_mode = None - async def async_turn_on(self) -> None: """Turn the entity on.""" _LOGGER.debug( @@ -226,16 +224,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): await self.async_turn_off() return + if hvac_mode == HVACMode.HEAT_COOL: + hvac_mode = HVACMode.AUTO + # If device is off, turn on first. if not self.data.is_on: await self.async_turn_on() - # When we request hvac mode while turning on the device, the previously set - # hvac mode is displayed first and then switches to the requested hvac mode. - # To prevent this, set the requested hvac mode here so that it will be set - # immediately on the next update. - self._requested_hvac_mode = HVAC_TO_STR.get(hvac_mode) - _LOGGER.debug( "[%s:%s] async_set_hvac_mode: %s", self.coordinator.device_name, @@ -244,9 +239,8 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) await self.async_call_api( self.coordinator.api.async_set_hvac_mode( - self.property_id, self._requested_hvac_mode - ), - self.reset_requested_hvac_mode, + self.property_id, HVAC_TO_STR.get(hvac_mode) + ) ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -257,6 +251,8 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.property_id, preset_mode, ) + if preset_mode == PRESET_NONE: + preset_mode = "cool" if self.preset_mode == "energy_saving" else "fan" await self.async_call_api( self.coordinator.api.async_set_hvac_mode(self.property_id, preset_mode) ) @@ -301,59 +297,50 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) ) - def _round_by_step(self, temperature: float) -> float: - """Round the value by step.""" - if ( - target_temp := display_temp( - self.coordinator.hass, - temperature, - self.coordinator.hass.config.units.temperature_unit, - self.target_temperature_step or 1, - ) - ) is not None: - return target_temp - - return temperature - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + return + + if hvac_mode == HVACMode.HEAT_COOL: + hvac_mode = HVACMode.AUTO + + # If device is off, turn on first. + if not self.data.is_on: + await self.async_turn_on() + + if hvac_mode and hvac_mode != self.hvac_mode: + await self.async_set_hvac_mode(HVACMode(hvac_mode)) + _LOGGER.debug( "[%s:%s] async_set_temperature: %s", self.coordinator.device_name, self.property_id, kwargs, ) - if hvac_mode := kwargs.get(ATTR_HVAC_MODE): - await self.async_set_hvac_mode(HVACMode(hvac_mode)) - if hvac_mode == HVACMode.OFF: - return - - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - if ( - target_temp := self._round_by_step(temperature) - ) != self.target_temperature: + if temperature := kwargs.get(ATTR_TEMPERATURE): + if self.data.step >= 1: + temperature = int(temperature) + if temperature != self.target_temperature: await self.async_call_api( self.coordinator.api.async_set_target_temperature( - self.property_id, target_temp + self.property_id, + temperature, ) ) - if (temperature_low := kwargs.get(ATTR_TARGET_TEMP_LOW)) is not None: - if ( - target_temp_low := self._round_by_step(temperature_low) - ) != self.target_temperature_low: - await self.async_call_api( - self.coordinator.api.async_set_target_temperature_low( - self.property_id, target_temp_low - ) - ) - - if (temperature_high := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None: - if ( - target_temp_high := self._round_by_step(temperature_high) - ) != self.target_temperature_high: - await self.async_call_api( - self.coordinator.api.async_set_target_temperature_high( - self.property_id, target_temp_high - ) + if (temperature_low := kwargs.get(ATTR_TARGET_TEMP_LOW)) and ( + temperature_high := kwargs.get(ATTR_TARGET_TEMP_HIGH) + ): + if self.data.step >= 1: + temperature_low = int(temperature_low) + temperature_high = int(temperature_high) + await self.async_call_api( + self.coordinator.api.async_set_target_temperature_low_high( + self.property_id, + temperature_low, + temperature_high, ) + ) diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index fd1b31e80bf..754969ff549 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -18,6 +18,7 @@ 'max_temp': 86, 'min_temp': 64, 'preset_modes': list([ + 'none', 'air_clean', ]), 'swing_horizontal_modes': list([ @@ -78,8 +79,9 @@ ]), 'max_temp': 86, 'min_temp': 64, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ + 'none', 'air_clean', ]), 'supported_features': , From 56c53fdb9b9e5b34d4a7f39af0ee3572cbcb7147 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 20:14:44 +0200 Subject: [PATCH 126/235] Allow Bluetooth proxy for Shelly devices only if Zigbee firmware is not active (#149193) Co-authored-by: Shay Levy Co-authored-by: Norbert Rittel --- homeassistant/components/shelly/__init__.py | 2 +- homeassistant/components/shelly/config_flow.py | 4 ++-- homeassistant/components/shelly/coordinator.py | 4 ++-- homeassistant/components/shelly/strings.json | 2 +- tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_config_flow.py | 8 ++++---- tests/components/shelly/test_coordinator.py | 6 +++--- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 0467b93a7c8..5582ab488df 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -298,7 +298,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) translation_key="firmware_unsupported", translation_placeholders={"device": entry.title}, ) - runtime_data.rpc_zigbee_enabled = device.zigbee_enabled + runtime_data.rpc_zigbee_firmware = device.zigbee_firmware runtime_data.rpc_supports_scripts = await device.supports_scripts() if runtime_data.rpc_supports_scripts: runtime_data.rpc_script_events = await get_rpc_scripts_event_types( diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index bde57f6f9bc..d310f3525c5 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -475,8 +475,8 @@ class OptionsFlowHandler(OptionsFlow): return self.async_abort(reason="cannot_connect") if not supports_scripts: return self.async_abort(reason="no_scripts_support") - if self.config_entry.runtime_data.rpc_zigbee_enabled: - return self.async_abort(reason="zigbee_enabled") + if self.config_entry.runtime_data.rpc_zigbee_firmware: + return self.async_abort(reason="zigbee_firmware") if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 9291d7aa70f..18430da8841 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -94,7 +94,7 @@ class ShellyEntryData: rpc_poll: ShellyRpcPollingCoordinator | None = None rpc_script_events: dict[int, list[str]] | None = None rpc_supports_scripts: bool | None = None - rpc_zigbee_enabled: bool | None = None + rpc_zigbee_firmware: bool | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] @@ -730,7 +730,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if not self.sleep_period: if ( self.config_entry.runtime_data.rpc_supports_scripts - and not self.config_entry.runtime_data.rpc_zigbee_enabled + and not self.config_entry.runtime_data.rpc_zigbee_firmware ): await self._async_connect_ble_scanner() else: diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index c1d520a59f1..2bb5cd73bfd 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -105,7 +105,7 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner.", - "zigbee_enabled": "Device with Zigbee enabled cannot be used as a Bluetooth scanner. Please disable it to use the device as a Bluetooth scanner." + "zigbee_firmware": "A device with Zigbee firmware cannot be used as a Bluetooth scanner. Please switch to Matter firmware to use the device as a Bluetooth scanner." } }, "selector": { diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 4eccb075b67..47ff723bddc 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -548,6 +548,7 @@ def _mock_rpc_device(version: str | None = None): ), xmod_info={}, zigbee_enabled=False, + zigbee_firmware=False, ip_address="10.10.10.10", ) type(device).name = PropertyMock(return_value="Test name") diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 93893035a3e..3282756fe28 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -870,17 +870,17 @@ async def test_options_flow_abort_no_scripts_support( assert result["reason"] == "no_scripts_support" -async def test_options_flow_abort_zigbee_enabled( +async def test_options_flow_abort_zigbee_firmware( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: - """Test ble options abort if Zigbee is enabled for the device.""" - monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", True) + """Test ble options abort if Zigbee firmware is active.""" + monkeypatch.setattr(mock_rpc_device, "zigbee_firmware", True) entry = await init_integration(hass, 4) result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "zigbee_enabled" + assert result["reason"] == "zigbee_firmware" async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 5b4372fe938..ff61eda626f 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -864,7 +864,7 @@ async def test_rpc_update_entry_fw_ver( @pytest.mark.parametrize( - ("supports_scripts", "zigbee_enabled", "result"), + ("supports_scripts", "zigbee_firmware", "result"), [ (True, False, True), (True, True, False), @@ -877,14 +877,14 @@ async def test_rpc_runs_connected_events_when_initialized( mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, supports_scripts: bool, - zigbee_enabled: bool, + zigbee_firmware: bool, result: bool, ) -> None: """Test RPC runs connected events when initialized.""" monkeypatch.setattr( mock_rpc_device, "supports_scripts", AsyncMock(return_value=supports_scripts) ) - monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", zigbee_enabled) + monkeypatch.setattr(mock_rpc_device, "zigbee_firmware", zigbee_firmware) monkeypatch.setattr(mock_rpc_device, "initialized", False) await init_integration(hass, 2) From 1d9f779b2a8b0112d93d6e7527d20549558e0668 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 21:03:36 +0200 Subject: [PATCH 127/235] Add missing hyphen to "case-sensitive" in `tuya` (#149400) --- homeassistant/components/tuya/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 954f5dbda8a..fd3a680ed3c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -2,13 +2,13 @@ "config": { "step": { "reauth_user_code": { - "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app.", "data": { "user_code": "User code" } }, "user": { - "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app.", "data": { "user_code": "User code" } From b7b733efc3b55b313909ec0c6a03da79f1ad8d99 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 21:03:45 +0200 Subject: [PATCH 128/235] Use common state for "Normal" in `switchbot` (#149399) --- homeassistant/components/switchbot/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 6077861e1c6..35482016e90 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -34,7 +34,7 @@ } }, "encrypted_auth": { - "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case sensitive.", + "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case-sensitive.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -206,7 +206,7 @@ }, "preset_mode": { "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "natural": "Natural", "sleep": "Sleep", "baby": "Baby" From 208dde10e64f45ae7a57927f9bcf1017db3cd646 Mon Sep 17 00:00:00 2001 From: Alex Hermann Date: Thu, 24 Jul 2025 21:08:47 +0200 Subject: [PATCH 129/235] Make default title configurable in XMPP (#149379) --- homeassistant/components/xmpp/notify.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 968f925d1e8..6ad0c1671a9 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -48,6 +48,7 @@ ATTR_URL = "url" ATTR_URL_TEMPLATE = "url_template" ATTR_VERIFY = "verify" +CONF_TITLE = "title" CONF_TLS = "tls" CONF_VERIFY = "verify" @@ -64,6 +65,7 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( vol.Optional(CONF_ROOM, default=""): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, + vol.Optional(CONF_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, } ) @@ -82,6 +84,7 @@ async def async_get_service( config.get(CONF_TLS), config.get(CONF_VERIFY), config.get(CONF_ROOM), + config.get(CONF_TITLE), hass, ) @@ -89,7 +92,9 @@ async def async_get_service( class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, resource, password, recipient, tls, verify, room, hass): + def __init__( + self, sender, resource, password, recipient, tls, verify, room, title, hass + ): """Initialize the service.""" self._hass = hass self._sender = sender @@ -99,10 +104,11 @@ class XmppNotificationService(BaseNotificationService): self._tls = tls self._verify = verify self._room = room + self._title = title async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + title = kwargs.get(ATTR_TITLE, self._title) text = f"{title}: {message}" if title else message data = kwargs.get(ATTR_DATA) timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT) if data else None From fbe257f9976230ed4466aa565ae57a49654ef71b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:19:30 +0100 Subject: [PATCH 130/235] Add quality scale file to ring integration (#136454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/ring/quality_scale.yaml | 71 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ring/quality_scale.yaml diff --git a/homeassistant/components/ring/quality_scale.yaml b/homeassistant/components/ring/quality_scale.yaml new file mode 100644 index 00000000000..64bc5c23c3f --- /dev/null +++ b/homeassistant/components/ring/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: + status: exempt + comment: The integration does not register services + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: The integration does not register custom service actions + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: todo + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have any options configuration parameters + + # Gold + entity-translations: + status: todo + comment: Use device class translations for volume sensor and number + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + dynamic-devices: todo + discovery-update-info: + status: exempt + comment: The integration uses ring cloud api to identify devices and \ + does not use network identifiers + repair-issues: done + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 04812e9aefa..61c600c943f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -841,7 +841,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rfxtrx", "rhasspy", "ridwell", - "ring", "ripple", "risco", "rituals_perfume_genie", From dbc2b1354b235d17dcc5ec25f4d015e8eac54c19 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 24 Jul 2025 22:11:47 +0200 Subject: [PATCH 131/235] UnifiProtect refactor sensor retrieval in tests to use get_sensor_by_key function (#149398) --- tests/components/unifiprotect/test_sensor.py | 63 ++++++++++++++------ 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 9489a49bf22..c65b3ac8e4e 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.components.unifiprotect.sensor import ( NVR_DISABLED_SENSORS, NVR_SENSORS, SENSE_SENSORS, + ProtectSensorEntityDescription, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -55,6 +56,16 @@ from .utils import ( from tests.common import async_capture_events + +def get_sensor_by_key(sensors: tuple, key: str) -> ProtectSensorEntityDescription: + """Get sensor description by key.""" + for sensor in sensors: + if sensor.key == key: + return sensor + raise ValueError(f"Sensor with key '{key}' not found") + + +# Constants for test slicing (subsets of sensor tuples) CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] @@ -123,7 +134,9 @@ async def test_sensor_setup_sensor( # BLE signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, ALL_DEVICES_SENSORS[1] + Platform.SENSOR, + sensor_all, + get_sensor_by_key(ALL_DEVICES_SENSORS, "ble_signal"), ) entity = entity_registry.async_get(entity_id) @@ -269,7 +282,7 @@ async def test_sensor_nvr_missing_values( assert_entity_counts(hass, Platform.SENSOR, 12, 9) # Uptime - description = NVR_SENSORS[0] + description = get_sensor_by_key(NVR_SENSORS, "uptime") unique_id, entity_id = ids_from_device_description( Platform.SENSOR, nvr, description ) @@ -285,8 +298,8 @@ async def test_sensor_nvr_missing_values( assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Memory - description = NVR_SENSORS[8] + # Recording capacity + description = get_sensor_by_key(NVR_SENSORS, "record_capacity") unique_id, entity_id = ids_from_device_description( Platform.SENSOR, nvr, description ) @@ -300,8 +313,8 @@ async def test_sensor_nvr_missing_values( assert state.state == "0" assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Memory - description = NVR_DISABLED_SENSORS[2] + # Memory utilization + description = get_sensor_by_key(NVR_DISABLED_SENSORS, "memory_utilization") unique_id, entity_id = ids_from_device_description( Platform.SENSOR, nvr, description ) @@ -372,9 +385,9 @@ async def test_sensor_setup_camera( assert state.state == expected_values[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Wired signal + # Wired signal (phy_rate / link speed) unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[2] + Platform.SENSOR, doorbell, get_sensor_by_key(ALL_DEVICES_SENSORS, "phy_rate") ) entity = entity_registry.async_get(entity_id) @@ -391,7 +404,9 @@ async def test_sensor_setup_camera( # WiFi signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[3] + Platform.SENSOR, + doorbell, + get_sensor_by_key(ALL_DEVICES_SENSORS, "wifi_signal"), ) entity = entity_registry.async_get(entity_id) @@ -422,7 +437,9 @@ async def test_sensor_setup_camera_with_last_trip_time( # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, MOTION_TRIP_SENSORS[0] + Platform.SENSOR, + doorbell, + get_sensor_by_key(MOTION_TRIP_SENSORS, "motion_last_trip_time"), ) entity = entity_registry.async_get(entity_id) @@ -447,7 +464,7 @@ async def test_sensor_update_alarm( assert_entity_counts(hass, Platform.SENSOR, 22, 14) _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[4] + Platform.SENSOR, sensor_all, get_sensor_by_key(SENSE_SENSORS, "alarm_sound") ) event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") @@ -498,7 +515,9 @@ async def test_sensor_update_alarm_with_last_trip_time( # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[-3] + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "door_last_trip_time"), ) entity = entity_registry.async_get(entity_id) @@ -529,7 +548,9 @@ async def test_camera_update_license_plate( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -644,7 +665,9 @@ async def test_camera_update_license_plate_changes_number_during_detect( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -731,7 +754,9 @@ async def test_camera_update_license_plate_multiple_updates( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -854,7 +879,9 @@ async def test_camera_update_license_no_dupes( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -946,6 +973,8 @@ async def test_sensor_precision( assert_entity_counts(hass, Platform.SENSOR, 22, 14) nvr: NVR = ufp.api.bootstrap.nvr - _, entity_id = ids_from_device_description(Platform.SENSOR, nvr, NVR_SENSORS[6]) + _, entity_id = ids_from_device_description( + Platform.SENSOR, nvr, get_sensor_by_key(NVR_SENSORS, "resolution_4K") + ) assert hass.states.get(entity_id).state == "17.49" From 4cc4bd3b9a5999a804dcd878148dcf8aadf85bf1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:28:56 -0400 Subject: [PATCH 132/235] Remove redundant async_set_context from platforms (#149403) --- homeassistant/components/template/binary_sensor.py | 1 - homeassistant/components/template/cover.py | 1 - homeassistant/components/template/fan.py | 1 - homeassistant/components/template/light.py | 1 - homeassistant/components/template/lock.py | 1 - homeassistant/components/template/switch.py | 1 - homeassistant/components/template/vacuum.py | 1 - 7 files changed, 7 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 567e9e3a110..a2c5c7d460a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -370,7 +370,6 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity def _set_state(self, state, _=None): """Set up auto off.""" self._attr_is_on = state - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() if not state: diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 8f88baea091..e8739fa8207 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -492,7 +492,6 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): write_ha_state = True if not self._attr_assumed_state: - self.async_set_context(self.coordinator.data["context"]) write_ha_state = True elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 13d2414aea2..2d0d06f86a1 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -561,5 +561,4 @@ class TriggerFanEntity(TriggerEntity, AbstractTemplateFan): write_ha_state = True if write_ha_state: - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 802fc145427..07591ce9653 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1166,7 +1166,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): raw = self._rendered.get(CONF_STATE) self._state = template.result_as_boolean(raw) - self.async_set_context(self.coordinator.data["context"]) write_ha_state = True elif self._optimistic and len(self._rendered) > 0: # In case any non optimistic template diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index a2f1f56bea2..848469b0ca4 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -372,7 +372,6 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): write_ha_state = True if not self._optimistic: - self.async_set_context(self.coordinator.data["context"]) write_ha_state = True elif self._optimistic and len(self._rendered) > 0: # In case any non optimistic template diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index b1d72084ae7..bd271e4b17c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -295,7 +295,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): raw = self._rendered.get(CONF_STATE) self._attr_is_on = template.result_as_boolean(raw) - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() elif self._attr_assumed_state and len(self._rendered) > 0: # In case name, icon, or friendly name have a template but diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 0056eca9b99..5ff99020f0d 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -404,5 +404,4 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): write_ha_state = True if write_ha_state: - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() From 3ba144c8b249fa1a4112633b1b931e6ad0481d39 Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Fri, 25 Jul 2025 00:38:48 +0100 Subject: [PATCH 133/235] Bump monzopy to 1.5.1 (#149410) --- homeassistant/components/monzo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index 7038cecd7ea..dc9a11be3ac 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/monzo", "iot_class": "cloud_polling", - "requirements": ["monzopy==1.4.2"] + "requirements": ["monzopy==1.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c0bb3df3a4..043e75ad64e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1449,7 +1449,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo -monzopy==1.4.2 +monzopy==1.5.1 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b028a880bfd..fc0b2640043 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1241,7 +1241,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo -monzopy==1.4.2 +monzopy==1.5.1 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 From 59ece455d95e2add3fa1dfb1fce0d4f4e8b414fc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Jul 2025 02:24:25 +0200 Subject: [PATCH 134/235] Update numpy to 2.3.2 (#149411) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index eae58caa255..4de2a39ec32 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.3.0"] + "requirements": ["numpy==2.3.2"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 75253099cdb..48a89f5a96a 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.3.0", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.3.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 6eaee7f1534..8ba8904751e 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.3.0"] + "requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.3.2"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 15d96469ee4..1144fd7a4af 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.3.0", + "numpy==2.3.2", "Pillow==11.3.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index e35c10a9ece..a6d0f8a0427 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.3.0"] + "requirements": ["numpy==2.3.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9f0e0408efd..8316bed251d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -118,7 +118,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.0 +numpy==2.3.2 pandas==2.3.0 # Constrain multidict to avoid typing issues diff --git a/requirements_all.txt b/requirements_all.txt index 043e75ad64e..e284bf2adb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.3.0 +numpy==2.3.2 # homeassistant.components.nyt_games nyt_games==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc0b2640043..b0cd9d3a77c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.3.0 +numpy==2.3.2 # homeassistant.components.nyt_games nyt_games==0.5.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b45d48aeff4..13bb3384258 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -144,7 +144,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.0 +numpy==2.3.2 pandas==2.3.0 # Constrain multidict to avoid typing issues From 7e9da052cae8077b4969e5433dfa3799b430ca18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 25 Jul 2025 08:17:26 +0200 Subject: [PATCH 135/235] Update aioairzone-cloud to v0.7.1 (#149388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 0747678c5a4..8f89ec88271 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.7.0"] + "requirements": ["aioairzone-cloud==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e284bf2adb3..dbb814bfcde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.7.0 +aioairzone-cloud==0.7.1 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0cd9d3a77c..6753f4a9fce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.7.0 +aioairzone-cloud==0.7.1 # homeassistant.components.airzone aioairzone==1.0.0 From 95d4dc678cf79cf91cc7e38061532af7b3132e77 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 25 Jul 2025 12:14:36 +0200 Subject: [PATCH 136/235] Add option traffic_mode in here_travel_time (#146676) --- .../components/here_travel_time/__init__.py | 31 ++++++++++- .../here_travel_time/config_flow.py | 17 +++++- .../components/here_travel_time/const.py | 1 + .../here_travel_time/coordinator.py | 12 ++++- .../components/here_travel_time/model.py | 3 +- .../components/here_travel_time/strings.json | 5 +- .../here_travel_time/test_config_flow.py | 24 ++++++++- .../components/here_travel_time/test_init.py | 32 +++++++++++- .../here_travel_time/test_sensor.py | 52 +++++++++++++++++-- 9 files changed, 166 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 5393dfa5050..741a9a1058c 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations +import logging + from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from .const import TRAVEL_MODE_PUBLIC +from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC from .coordinator import ( HereConfigEntry, HERERoutingDataUpdateCoordinator, @@ -15,6 +17,8 @@ from .coordinator import ( PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool: """Set up HERE Travel Time from a config entry.""" @@ -43,3 +47,28 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: HereConfigEntry +) -> bool: + """Migrate an old config entry.""" + + if config_entry.version == 1 and config_entry.minor_version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + options[CONF_TRAFFIC_MODE] = True + + hass.config_entries.async_update_entry( + config_entry, options=options, version=1, minor_version=2 + ) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 6425b5ffbed..5ff0a68bc9a 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, EntitySelector, LocationSelector, TimeSelector, @@ -50,6 +51,7 @@ from .const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_NAME, DOMAIN, ROUTE_MODE_FASTEST, @@ -65,6 +67,7 @@ DEFAULT_OPTIONS = { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: None, CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, } @@ -102,6 +105,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for HERE Travel Time.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Init Config Flow.""" @@ -307,7 +311,9 @@ class HERETravelTimeOptionsFlow(OptionsFlow): """Manage the HERE Travel Time options.""" if user_input is not None: self._config = user_input - return await self.async_step_time_menu() + if self._config[CONF_TRAFFIC_MODE]: + return await self.async_step_time_menu() + return self.async_create_entry(title="", data=self._config) schema = self.add_suggested_values_to_schema( vol.Schema( @@ -318,12 +324,21 @@ class HERETravelTimeOptionsFlow(OptionsFlow): CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] ), ): vol.In(ROUTE_MODES), + vol.Optional( + CONF_TRAFFIC_MODE, + default=self.config_entry.options.get( + CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE] + ), + ): BooleanSelector(), } ), { CONF_ROUTE_MODE: self.config_entry.options.get( CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] ), + CONF_TRAFFIC_MODE: self.config_entry.options.get( + CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE] + ), }, ) diff --git a/homeassistant/components/here_travel_time/const.py b/homeassistant/components/here_travel_time/const.py index 785070cd3b1..cc208d95abe 100644 --- a/homeassistant/components/here_travel_time/const.py +++ b/homeassistant/components/here_travel_time/const.py @@ -19,6 +19,7 @@ CONF_ARRIVAL = "arrival" CONF_DEPARTURE = "departure" CONF_ARRIVAL_TIME = "arrival_time" CONF_DEPARTURE_TIME = "departure_time" +CONF_TRAFFIC_MODE = "traffic_mode" DEFAULT_NAME = "HERE Travel Time" diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index d8c698554c9..0e447770ca9 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -13,6 +13,7 @@ from here_routing import ( Return, RoutingMode, Spans, + TrafficMode, TransportMode, ) import here_transit @@ -44,6 +45,7 @@ from .const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST, @@ -87,7 +89,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] _LOGGER.debug( ( "Requesting route for origin: %s, destination: %s, route_mode: %s," - " mode: %s, arrival: %s, departure: %s" + " mode: %s, arrival: %s, departure: %s, traffic_mode: %s" ), params.origin, params.destination, @@ -95,6 +97,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] TransportMode(params.travel_mode), params.arrival, params.departure, + params.traffic_mode, ) try: @@ -109,6 +112,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] routing_mode=params.route_mode, arrival_time=params.arrival, departure_time=params.departure, + traffic_mode=params.traffic_mode, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -350,6 +354,11 @@ def prepare_parameters( if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST else RoutingMode.SHORT ) + traffic_mode = ( + TrafficMode.DISABLED + if config_entry.options[CONF_TRAFFIC_MODE] is False + else TrafficMode.DEFAULT + ) return HERETravelTimeAPIParams( destination=destination, @@ -358,6 +367,7 @@ def prepare_parameters( route_mode=route_mode, arrival=arrival, departure=departure, + traffic_mode=traffic_mode, ) diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index a0534d2ff01..deb886f6805 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from typing import TypedDict -from here_routing import RoutingMode +from here_routing import RoutingMode, TrafficMode class HERETravelTimeData(TypedDict): @@ -32,3 +32,4 @@ class HERETravelTimeAPIParams: route_mode: RoutingMode arrival: datetime | None departure: datetime | None + traffic_mode: TrafficMode diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index 89350261299..639be3326f9 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -60,8 +60,11 @@ "step": { "init": { "data": { - "traffic_mode": "Traffic mode", + "traffic_mode": "Use traffic and time-aware routing", "route_mode": "Route mode" + }, + "data_description": { + "traffic_mode": "Needed for defining arrival/departure times" } }, "time_menu": { diff --git a/tests/components/here_travel_time/test_config_flow.py b/tests/components/here_travel_time/test_config_flow.py index ce210813fb2..82c75471896 100644 --- a/tests/components/here_travel_time/test_config_flow.py +++ b/tests/components/here_travel_time/test_config_flow.py @@ -6,7 +6,10 @@ from here_routing import HERERoutingError, HERERoutingUnauthorizedError import pytest from homeassistant import config_entries -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, @@ -17,6 +20,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DOMAIN, ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, @@ -86,6 +90,8 @@ async def option_init_result_fixture( CONF_MODE: TRAVEL_MODE_PUBLIC, CONF_NAME: "test", }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -249,6 +255,7 @@ async def test_step_destination_entity( CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: None, CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, } @@ -317,6 +324,8 @@ async def do_common_reconfiguration_steps(hass: HomeAssistant) -> None: unique_id="0123456789", data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) @@ -398,6 +407,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: domain=DOMAIN, unique_id="0123456789", data=DEFAULT_CONFIG, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) @@ -414,10 +425,16 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: False, }, ) - assert result["type"] is FlowResultType.MENU + assert result["type"] is FlowResultType.CREATE_ENTRY + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.options == { + CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: False, + } @pytest.mark.usefixtures("valid_response") @@ -441,6 +458,7 @@ async def test_options_flow_arrival_time_step( assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: "08:00:00", + CONF_TRAFFIC_MODE: True, } @@ -465,6 +483,7 @@ async def test_options_flow_departure_time_step( assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_DEPARTURE_TIME: "08:00:00", + CONF_TRAFFIC_MODE: True, } @@ -481,4 +500,5 @@ async def test_options_flow_no_time_step( entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: True, } diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index ff09c7e6ae9..4dbddd46633 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -4,14 +4,19 @@ from datetime import datetime import pytest -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DOMAIN, ROUTE_MODE_FASTEST, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import DEFAULT_CONFIG @@ -44,9 +49,34 @@ async def test_unload_entry(hass: HomeAssistant, options) -> None: unique_id="0123456789", data=DEFAULT_CONFIG, options=options, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("valid_response") +async def test_migrate_entry_v1_1_v1_2( + hass: HomeAssistant, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + version=1, + minor_version=1, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.minor_version == 2 + assert updated_entry.options[CONF_TRAFFIC_MODE] is True diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 7c8946b7049..b96e77a6b6d 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -11,6 +11,7 @@ from here_routing import ( Return, RoutingMode, Spans, + TrafficMode, TransportMode, ) from here_transit import ( @@ -21,7 +22,10 @@ from here_transit import ( ) import pytest -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, @@ -32,6 +36,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_SCAN_INTERVAL, DOMAIN, ICON_BICYCLE, @@ -85,29 +90,33 @@ from tests.common import ( @pytest.mark.parametrize( - ("mode", "icon", "arrival_time", "departure_time"), + ("mode", "icon", "traffic_mode", "arrival_time", "departure_time"), [ ( TRAVEL_MODE_CAR, ICON_CAR, + False, None, None, ), ( TRAVEL_MODE_BICYCLE, ICON_BICYCLE, + True, None, None, ), ( TRAVEL_MODE_PEDESTRIAN, ICON_PEDESTRIAN, + True, None, "08:00:00", ), ( TRAVEL_MODE_TRUCK, ICON_TRUCK, + True, None, "08:00:00", ), @@ -118,6 +127,7 @@ async def test_sensor( hass: HomeAssistant, mode, icon, + traffic_mode, arrival_time, departure_time, ) -> None: @@ -137,9 +147,12 @@ async def test_sensor( }, options={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: traffic_mode, CONF_ARRIVAL_TIME: arrival_time, CONF_DEPARTURE_TIME: departure_time, }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -197,6 +210,8 @@ async def test_circular_ref( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -228,7 +243,10 @@ async def test_public_transport(hass: HomeAssistant) -> None: CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: "08:00:00", CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -260,6 +278,8 @@ async def test_no_attribution_response(hass: HomeAssistant) -> None: CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -307,6 +327,8 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -324,6 +346,7 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non routing_mode=RoutingMode.FAST, arrival_time=None, departure_time=None, + traffic_mode=TrafficMode.DEFAULT, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -346,6 +369,8 @@ async def test_destination_entity_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -374,6 +399,8 @@ async def test_origin_entity_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -406,6 +433,8 @@ async def test_invalid_destination_entity_state( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -440,6 +469,8 @@ async def test_invalid_origin_entity_state( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -476,6 +507,8 @@ async def test_route_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -587,7 +620,12 @@ async def test_restore_state(hass: HomeAssistant) -> None: # create and add entry mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id=DOMAIN, data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS + domain=DOMAIN, + unique_id=DOMAIN, + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) mock_entry.add_to_hass(hass) @@ -656,6 +694,8 @@ async def test_transit_errors( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -682,6 +722,8 @@ async def test_routing_rate_limit( unique_id="0123456789", data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -739,6 +781,8 @@ async def test_transit_rate_limit( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -791,6 +835,8 @@ async def test_multiple_sections( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) From b7da31a0212f9399de20a14f1d7018d7b7f13950 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:15:42 +0300 Subject: [PATCH 137/235] Bump pyosoenergyapi to 1.2.3 (#149422) --- homeassistant/components/osoenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index 6129aa379f7..5f0e1b93027 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.1.5"] + "requirements": ["pyosoenergyapi==1.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbb814bfcde..45bac7969ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2215,7 +2215,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.5 +pyosoenergyapi==1.2.3 # homeassistant.components.opentherm_gw pyotgw==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6753f4a9fce..ab46679547b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1842,7 +1842,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.5 +pyosoenergyapi==1.2.3 # homeassistant.components.opentherm_gw pyotgw==2.2.2 From f7cc260336e6dd3f98d18f89a7face3a7ba1b478 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 25 Jul 2025 12:20:33 +0200 Subject: [PATCH 138/235] Add quality scale for devolo Home Network (#131510) Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> --- .../devolo_home_network/manifest.json | 1 + .../devolo_home_network/quality_scale.yaml | 84 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/devolo_home_network/quality_scale.yaml diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index 31f3a51ebeb..37fb2682883 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,6 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["devolo_plc_api"], + "quality_scale": "silver", "requirements": ["devolo-plc-api==1.5.1"], "zeroconf": [ { diff --git a/homeassistant/components/devolo_home_network/quality_scale.yaml b/homeassistant/components/devolo_home_network/quality_scale.yaml new file mode 100644 index 00000000000..dda228c47e3 --- /dev/null +++ b/homeassistant/components/devolo_home_network/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + A change of the IP address is covered by discovery-update-info and a change of the password is covered by reauthentication-flow. No other configuration options are available. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: todo + comment: | + The tracked devices could be own devices with a manual delete option as the API cannot distinguish between stale devices and devices that are not home. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 61c600c943f..b42e1e415aa 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -285,7 +285,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "devialet", "device_sun_light_trigger", "devolo_home_control", - "devolo_home_network", "dexcom", "dhcp", "dialogflow", @@ -1320,7 +1319,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "devialet", "device_sun_light_trigger", "devolo_home_control", - "devolo_home_network", "dexcom", "dhcp", "dialogflow", From 6920dec352f938d34b4f825ec35f0fc99d2f8928 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 25 Jul 2025 12:55:42 +0200 Subject: [PATCH 139/235] Rework devolo Home Control config flow (#147121) --- .../devolo_home_control/config_flow.py | 109 ++++++++---------- 1 file changed, 49 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index c4f57b2398a..64220949270 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -7,45 +7,39 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlow, - ConfigFlowResult, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import configure_mydevolo from .const import DOMAIN, SUPPORTED_MODEL_TYPES from .exceptions import CredentialsInvalid, UuidChanged +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a devolo HomeControl config flow.""" VERSION = 1 - _reauth_entry: ConfigEntry - - def __init__(self) -> None: - """Initialize devolo Home Control flow.""" - self.data_schema = { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - if user_input is None: - return self._show_form(step_id="user") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form(step_id="user", errors={"base": "invalid_auth"}) + errors: dict[str, str] = {} + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -61,42 +55,47 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by zeroconf.""" - if user_input is None: - return self._show_form(step_id="zeroconf_confirm") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form( - step_id="zeroconf_confirm", errors={"base": "invalid_auth"} - ) + errors: dict[str, str] = {} + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="zeroconf_confirm", data_schema=DATA_SCHEMA, errors=errors + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauthentication.""" - self._reauth_entry = self._get_reauth_entry() - self.data_schema = { - vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, - vol.Required(CONF_PASSWORD): str, - } return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by reauthentication.""" - if user_input is None: - return self._show_form(step_id="reauth_confirm") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form( - step_id="reauth_confirm", errors={"base": "invalid_auth"} - ) - except UuidChanged: - return self._show_form( - step_id="reauth_confirm", errors={"base": "reauth_failed"} - ) + errors: dict[str, str] = {} + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=self.init_data[CONF_USERNAME]): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + except UuidChanged: + errors["base"] = "reauth_failed" + + return self.async_show_form( + step_id="reauth_confirm", data_schema=data_schema, errors=errors + ) async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Connect to mydevolo.""" @@ -119,21 +118,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - if self._reauth_entry.unique_id != uuid: + if self.unique_id != uuid: # The old user and the new user are not the same. This could mess-up everything as all unique IDs might change. raise UuidChanged + reauth_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( - self._reauth_entry, data=user_input, unique_id=uuid - ) - - @callback - def _show_form( - self, step_id: str, errors: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Show the form to the user.""" - return self.async_show_form( - step_id=step_id, - data_schema=vol.Schema(self.data_schema), - errors=errors if errors else {}, + reauth_entry, data=user_input, unique_id=uuid ) From 123cce6d96a30d7edcdf81fca0a068da1285b43f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 25 Jul 2025 14:26:32 +0300 Subject: [PATCH 140/235] Add configuration URL and model details to Shelly sub device info (#149404) --- homeassistant/components/shelly/button.py | 6 ++++++ homeassistant/components/shelly/climate.py | 3 +++ homeassistant/components/shelly/coordinator.py | 14 ++++++++++++-- homeassistant/components/shelly/entity.py | 15 +++++++++++++++ homeassistant/components/shelly/event.py | 3 +++ homeassistant/components/shelly/sensor.py | 3 +++ homeassistant/components/shelly/utils.py | 15 +++++++++++++++ 7 files changed, 57 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index ad03a373dba..2ab23441c98 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -237,12 +237,18 @@ class ShellyButton(ShellyBaseButton): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, suggested_area=coordinator.suggested_area, ) else: self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, suggested_area=coordinator.suggested_area, ) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index abc387f3efd..2a09e867dce 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -213,6 +213,9 @@ class BlockSleepingClimate( self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, sensor_block, suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 18430da8841..eba6b846fe4 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -145,11 +145,21 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + @cached_property + def configuration_url(self) -> str: + """Return the configuration URL for the device.""" + return f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}" + @cached_property def model(self) -> str: """Model of the device.""" return cast(str, self.config_entry.data[CONF_MODEL]) + @cached_property + def model_name(self) -> str | None: + """Model name of the device.""" + return get_shelly_model_name(self.model, self.sleep_period, self.device) + @cached_property def mac(self) -> str: """Mac address of the device.""" @@ -175,11 +185,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( connections={(CONNECTION_NETWORK_MAC, self.mac)}, identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=get_shelly_model_name(self.model, self.sleep_period, self.device), + model=self.model_name, model_id=self.model, sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.config_entry)}", - configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", + configuration_url=self.configuration_url, ) # We want to use the main device area as the suggested area for sub-devices. if (area_id := device_entry.area_id) is not None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index b80ac877a84..33a45a0e10f 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -371,6 +371,9 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, block, suggested_area=coordinator.suggested_area, ) @@ -417,6 +420,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, suggested_area=coordinator.suggested_area, ) @@ -536,6 +542,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, suggested_area=coordinator.suggested_area, ) self._last_value = None @@ -647,6 +656,9 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, block, suggested_area=coordinator.suggested_area, ) @@ -717,6 +729,9 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 2eb9ff00964..9e1c748c790 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -209,6 +209,9 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index cefcbb86a98..cdfa97357f2 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -141,6 +141,9 @@ class RpcEmeterPhaseSensor(RpcSensor): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, emeter_phase=description.emeter_phase, suggested_area=coordinator.suggested_area, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 1af365debfb..2ee960348dd 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -749,6 +749,9 @@ async def get_rpc_scripts_event_types( def get_rpc_device_info( device: RpcDevice, mac: str, + configuration_url: str, + model: str, + model_name: str | None = None, key: str | None = None, emeter_phase: str | None = None, suggested_area: str | None = None, @@ -771,8 +774,11 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}-{emeter_phase.lower()}")}, name=get_rpc_sub_device_name(device, key, emeter_phase), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) if ( @@ -786,8 +792,11 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}")}, name=get_rpc_sub_device_name(device, key), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) @@ -810,6 +819,9 @@ def get_blu_trv_device_info( def get_block_device_info( device: BlockDevice, mac: str, + configuration_url: str, + model: str, + model_name: str | None = None, block: Block | None = None, suggested_area: str | None = None, ) -> DeviceInfo: @@ -826,8 +838,11 @@ def get_block_device_info( identifiers={(DOMAIN, f"{mac}-{block.description}")}, name=get_block_sub_device_name(device, block), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) From e3ffb41650b6c080dff1ba0162c077ea41046662 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 13:52:01 +0200 Subject: [PATCH 141/235] Improve some option and state names in `home_connect` (#149373) --- .../components/home_connect/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 853d2bd2f8e..0b094a9d49a 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -193,11 +193,11 @@ "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner Brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser Brauner", "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener Melange", "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", @@ -279,7 +279,7 @@ "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", "cooking_oven_program_heating_mode_keep_warm": "Keep warm", "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", - "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products", "cooking_oven_program_heating_mode_desiccation": "Desiccation", "cooking_oven_program_heating_mode_defrost": "Defrost", "cooking_oven_program_heating_mode_proof": "Proof", @@ -316,8 +316,8 @@ "laundry_care_washer_program_monsoon": "Monsoon", "laundry_care_washer_program_outdoor": "Outdoor", "laundry_care_washer_program_plush_toy": "Plush toy", - "laundry_care_washer_program_shirts_blouses": "Shirts blouses", - "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_shirts_blouses": "Shirts/blouses", + "laundry_care_washer_program_sport_fitness": "Sport/fitness", "laundry_care_washer_program_towels": "Towels", "laundry_care_washer_program_water_proof": "Water proof", "laundry_care_washer_program_power_speed_59": "Power speed <59 min", @@ -1291,9 +1291,9 @@ "state": { "cooking_hood_enum_type_color_temperature_custom": "Custom", "cooking_hood_enum_type_color_temperature_warm": "Warm", - "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral", + "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to neutral", "cooking_hood_enum_type_color_temperature_neutral": "Neutral", - "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold", + "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to cold", "cooking_hood_enum_type_color_temperature_cold": "Cold" } }, From c1fa721a57525f9c87343257725ecce5705df480 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 25 Jul 2025 14:03:44 +0100 Subject: [PATCH 142/235] Revert "Use OptionsFlowWithReload in mqtt" (#149431) --- homeassistant/components/mqtt/__init__.py | 11 +++++++++++ homeassistant/components/mqtt/config_flow.py | 6 +++--- tests/components/mqtt/test_config_flow.py | 16 +++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 4f00c4da958..9e3dc59f852 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -246,6 +246,14 @@ MQTT_PUBLISH_SCHEMA = vol.Schema( ) +async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle signals of config entry being updated. + + Causes for this is config entry options changing. + """ + await hass.config_entries.async_reload(entry.entry_id) + + @callback def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: """Unregister open config issues.""" @@ -427,6 +435,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data.subscriptions_to_restore ) mqtt_data.subscriptions_to_restore = set() + mqtt_data.reload_dispatchers.append( + entry.add_update_listener(_async_config_entry_updated) + ) return (mqtt_data, conf) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 023872d410c..52f00c82c27 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -52,7 +52,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlowWithReload, + OptionsFlow, SubentryFlowResult, ) from homeassistant.const import ( @@ -2537,7 +2537,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class MQTTOptionsFlowHandler(OptionsFlowWithReload): +class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: @@ -3353,7 +3353,7 @@ def _validate_pki_file( async def async_get_broker_settings( # noqa: C901 - flow: ConfigFlow | OptionsFlowWithReload, + flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, user_input: dict[str, Any] | None, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index b45a4a66aa9..ce0a0c44a79 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -17,10 +17,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError -from homeassistant.components.mqtt.config_flow import ( - PWD_NOT_CHANGED, - MQTTOptionsFlowHandler, -) +from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( @@ -196,8 +193,8 @@ def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, Magi @pytest.fixture def mock_reload_after_entry_update() -> Generator[MagicMock]: """Mock out the reload after updating the entry.""" - with patch.object( - MQTTOptionsFlowHandler, "automatic_reload", return_value=False + with patch( + "homeassistant.components.mqtt._async_config_entry_updated" ) as mock_reload: yield mock_reload @@ -1333,11 +1330,11 @@ async def test_keepalive_validation( assert result["reason"] == "reconfigure_successful" -@pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_disable_birth_will( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, + mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" await mqtt_mock_entry() @@ -1351,6 +1348,7 @@ async def test_disable_birth_will( }, ) await hass.async_block_till_done() + mock_reload_after_entry_update.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1389,6 +1387,10 @@ async def test_disable_birth_will( mqtt.CONF_WILL_MESSAGE: {}, } + await hass.async_block_till_done() + # assert that the entry was reloaded with the new config + assert mock_reload_after_entry_update.call_count == 1 + async def test_invalid_discovery_prefix( hass: HomeAssistant, From 4bbb94f43deb199ae9762f9ed3ce97197340634e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Jul 2025 15:05:20 +0200 Subject: [PATCH 143/235] Update coverage to 7.10.0 (#149412) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index cc9eff9dc3f..6c0fc02df58 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.10 -coverage==7.9.1 +coverage==7.10.0 freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 From f3513f7f29c0df256eac2d1a6044ffebf28dd42f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 19:01:57 +0200 Subject: [PATCH 144/235] Add missing hyphen to "case-sensitive" in `tplink` (#149363) --- homeassistant/components/tplink/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index a7f9dfbcb09..70eff4a34c4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -30,8 +30,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "Your TP-Link cloud username which is the full email and is case sensitive.", - "password": "Your TP-Link cloud password which is case sensitive." + "username": "Your TP-Link cloud username which is the full email and is case-sensitive.", + "password": "Your TP-Link cloud password which is case-sensitive." } }, "discovery_auth_confirm": { From 356ac74fa507e96169fb473105bdab69c6cf041b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:07:07 +0200 Subject: [PATCH 145/235] Update orjson to 3.11.1 (#149442) --- 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 8316bed251d..88aa9418ddc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.11.0 +orjson==3.11.1 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index b1b43c80cd2..162f63ff064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.11.0", + "orjson==3.11.1", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index e4065bed83e..65d0309747e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ cryptography==45.0.3 Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.11.0 +orjson==3.11.1 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 65109ea000b5e09417bd8e9c7a2e751db6004a18 Mon Sep 17 00:00:00 2001 From: jvmahon Date: Fri, 25 Jul 2025 13:09:58 -0400 Subject: [PATCH 146/235] Fix Matter light get brightness (#149186) --- homeassistant/components/matter/light.py | 7 ++++++- tests/components/matter/test_light.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index c61fd0879fa..a86938730c9 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models import device_types from homeassistant.components.light import ( @@ -241,7 +242,7 @@ class MatterLight(MatterEntity, LightEntity): return int(color_temp) - def _get_brightness(self) -> int: + def _get_brightness(self) -> int | None: """Get brightness from matter.""" level_control = self._endpoint.get_cluster(clusters.LevelControl) @@ -255,6 +256,10 @@ class MatterLight(MatterEntity, LightEntity): self.entity_id, ) + if level_control.currentLevel is NullValue: + # currentLevel is a nullable value. + return None + return round( renormalize( level_control.currentLevel, diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index b600ededa6e..f9abf986170 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -131,6 +131,15 @@ async def test_dimmable_light( ) -> None: """Test a dimmable light.""" + # Test for currentLevel is None + set_node_attribute(matter_node, 1, 8, 0, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] is None + # Test that the light brightness is 50 (out of 254) set_node_attribute(matter_node, 1, 8, 0, 50) await trigger_subscription_callback(hass, matter_client) From aad1dbecb4e1cc287e1fac7971ca13a46c6ec6a6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 19:28:43 +0200 Subject: [PATCH 147/235] Fix spelling of "IP" and improve action descriptions in `lcn` (#149314) --- homeassistant/components/lcn/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 4e4ca7e0dcd..90d4bdcd4ad 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -70,7 +70,7 @@ }, "abort": { "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "already_configured": "PCHK connection using the same ip address/port is already configured." + "already_configured": "PCHK connection using the same IP address/port is already configured." } }, "issues": { @@ -156,7 +156,7 @@ }, "relays": { "name": "Relays", - "description": "Sets the relays status.", + "description": "Sets the relay states.", "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", @@ -168,7 +168,7 @@ }, "state": { "name": "State", - "description": "Relays states as string (1=on, 2=off, t=toggle, -=no change)." + "description": "Relay states as string (1=on, 2=off, t=toggle, -=no change)." } } }, @@ -322,7 +322,7 @@ }, "lock_keys": { "name": "Lock keys", - "description": "Locks keys.", + "description": "Sets the key lock states.", "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", From b3130c7929479e7122f8f7d463ccff5ca4f0b700 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:29:40 +0200 Subject: [PATCH 148/235] Bump aioautomower to 2.0.2 (#149441) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 0234ac58e39..798bd631e43 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.0.1"] + "requirements": ["aioautomower==2.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45bac7969ab..9c9c6c7d20f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.1 +aioautomower==2.0.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab46679547b..0d5a0efa684 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.1 +aioautomower==2.0.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 02eb1dd533b98ceca9978216dac9273b3dadedf3 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Fri, 25 Jul 2025 20:30:58 +0300 Subject: [PATCH 149/235] Bump pyosoenergyapi to 1.2.4 (#149439) --- homeassistant/components/osoenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index 5f0e1b93027..b47fb0fe08a 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.2.3"] + "requirements": ["pyosoenergyapi==1.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c9c6c7d20f..f308f39aa86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2215,7 +2215,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.2.3 +pyosoenergyapi==1.2.4 # homeassistant.components.opentherm_gw pyotgw==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d5a0efa684..daac6214663 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1842,7 +1842,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.2.3 +pyosoenergyapi==1.2.4 # homeassistant.components.opentherm_gw pyotgw==2.2.2 From a069b59efc50e7ed0f3a3b57c2edc67a98b92d82 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:55:40 -0400 Subject: [PATCH 150/235] Transition template types from string to platform keys (#149434) --- homeassistant/components/template/config_flow.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index bb5ee14c7d2..7e06ef51a4b 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -324,14 +324,14 @@ def validate_user_input( TEMPLATE_TYPES = [ - "alarm_control_panel", - "binary_sensor", - "button", - "image", - "number", - "select", - "sensor", - "switch", + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.IMAGE, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, ] CONFIG_FLOW = { From b2710c1bce76bbf84a3e78444c182b199f4833a7 Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Fri, 25 Jul 2025 11:10:39 -0700 Subject: [PATCH 151/235] Add smarttub cover sensor (#139134) Co-authored-by: Erik Montnemery --- .../components/smarttub/binary_sensor.py | 32 +++++++++++++++-- homeassistant/components/smarttub/const.py | 1 + .../components/smarttub/controller.py | 2 ++ homeassistant/components/smarttub/entity.py | 34 ++++++++++++++++--- homeassistant/components/smarttub/sensor.py | 20 +++++------ tests/components/smarttub/conftest.py | 11 ++++++ .../components/smarttub/test_binary_sensor.py | 11 ++++++ 7 files changed, 94 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index a120650e84b..1a329ce8a25 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from smarttub import Spa, SpaError, SpaReminder @@ -17,9 +18,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTR_ERRORS, ATTR_REMINDERS +from .const import ATTR_ERRORS, ATTR_REMINDERS, ATTR_SENSORS from .controller import SmartTubConfigEntry -from .entity import SmartTubEntity, SmartTubSensorBase +from .entity import ( + SmartTubEntity, + SmartTubExternalSensorBase, + SmartTubOnboardSensorBase, +) # whether the reminder has been snoozed (bool) ATTR_REMINDER_SNOOZED = "snoozed" @@ -44,6 +49,8 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = { ) } +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -62,6 +69,12 @@ async def async_setup_entry( SmartTubReminder(controller.coordinator, spa, reminder) for reminder in controller.coordinator.data[spa.id][ATTR_REMINDERS].values() ) + for sensor in controller.coordinator.data[spa.id][ATTR_SENSORS].values(): + name = sensor.name.strip("{}") + if name.startswith("cover-"): + entities.append( + SmartTubCoverSensor(controller.coordinator, spa, sensor) + ) async_add_entities(entities) @@ -79,7 +92,7 @@ async def async_setup_entry( ) -class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): +class SmartTubOnline(SmartTubOnboardSensorBase, BinarySensorEntity): """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @@ -192,3 +205,16 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity): ATTR_CREATED_AT: error.created_at.isoformat(), ATTR_UPDATED_AT: error.updated_at.isoformat(), } + + +class SmartTubCoverSensor(SmartTubExternalSensorBase, BinarySensorEntity): + """Wireless magnetic cover sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool: + """Return False if the cover is closed, True if open.""" + # magnet is True when the cover is closed, False when open + # device class OPENING wants True to mean open, False to mean closed + return not self.sensor.magnet diff --git a/homeassistant/components/smarttub/const.py b/homeassistant/components/smarttub/const.py index dadc66da942..8bf9da281a9 100644 --- a/homeassistant/components/smarttub/const.py +++ b/homeassistant/components/smarttub/const.py @@ -24,3 +24,4 @@ ATTR_LIGHTS = "lights" ATTR_PUMPS = "pumps" ATTR_REMINDERS = "reminders" ATTR_STATUS = "status" +ATTR_SENSORS = "sensors" diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index d8299bbd786..337959e0316 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -22,6 +22,7 @@ from .const import ( ATTR_LIGHTS, ATTR_PUMPS, ATTR_REMINDERS, + ATTR_SENSORS, ATTR_STATUS, DOMAIN, POLLING_TIMEOUT, @@ -108,6 +109,7 @@ class SmartTubController: ATTR_LIGHTS: {light.zone: light for light in full_status.lights}, ATTR_REMINDERS: {reminder.id: reminder for reminder in reminders}, ATTR_ERRORS: errors, + ATTR_SENSORS: {sensor.address: sensor for sensor in full_status.sensors}, } @callback diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 069fd50c5f2..53562fd887a 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -2,7 +2,7 @@ from typing import Any -from smarttub import Spa, SpaState +from smarttub import Spa, SpaSensor, SpaState from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN +from .const import ATTR_SENSORS, DOMAIN from .helpers import get_spa_name @@ -47,8 +47,8 @@ class SmartTubEntity(CoordinatorEntity): return self.coordinator.data[self.spa.id].get("status") -class SmartTubSensorBase(SmartTubEntity): - """Base class for SmartTub sensors.""" +class SmartTubOnboardSensorBase(SmartTubEntity): + """Base class for SmartTub onboard sensors.""" def __init__( self, @@ -65,3 +65,29 @@ class SmartTubSensorBase(SmartTubEntity): def _state(self): """Retrieve the underlying state from the spa.""" return getattr(self.spa_status, self._state_key) + + +class SmartTubExternalSensorBase(SmartTubEntity): + """Class for additional BLE wireless sensors sold separately.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: Spa, + sensor: SpaSensor, + ) -> None: + """Initialize the external sensor entity.""" + self.sensor_address = sensor.address + self._attr_unique_id = f"{spa.id}-externalsensor-{sensor.address}" + super().__init__(coordinator, spa, self._human_readable_name(sensor)) + + @staticmethod + def _human_readable_name(sensor: SpaSensor) -> str: + return " ".join( + word.capitalize() for word in sensor.name.strip("{}").split("-") + ) + + @property + def sensor(self) -> SpaSensor: + """Convenience property to access the smarttub.SpaSensor instance for this sensor.""" + return self.coordinator.data[self.spa.id][ATTR_SENSORS][self.sensor_address] diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 5116bfb3aee..64e5eec1f46 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .controller import SmartTubConfigEntry -from .entity import SmartTubSensorBase +from .entity import SmartTubOnboardSensorBase # the desired duration, in hours, of the cycle ATTR_DURATION = "duration" @@ -56,16 +56,16 @@ async def async_setup_entry( for spa in controller.spas: entities.extend( [ - SmartTubSensor(controller.coordinator, spa, "State", "state"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "State", "state"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Flow Switch", "flow_switch" ), - SmartTubSensor(controller.coordinator, spa, "Ozone", "ozone"), - SmartTubSensor(controller.coordinator, spa, "UV", "uv"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "Ozone", "ozone"), + SmartTubBuiltinSensor(controller.coordinator, spa, "UV", "uv"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Blowout Cycle", "blowout_cycle" ), - SmartTubSensor( + SmartTubBuiltinSensor( controller.coordinator, spa, "Cleanup Cycle", "cleanup_cycle" ), SmartTubPrimaryFiltrationCycle(controller.coordinator, spa), @@ -90,7 +90,7 @@ async def async_setup_entry( ) -class SmartTubSensor(SmartTubSensorBase, SensorEntity): +class SmartTubBuiltinSensor(SmartTubOnboardSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property @@ -105,7 +105,7 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): return self._state.lower() -class SmartTubPrimaryFiltrationCycle(SmartTubSensor): +class SmartTubPrimaryFiltrationCycle(SmartTubBuiltinSensor): """The primary filtration cycle.""" def __init__( @@ -145,7 +145,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): await self.coordinator.async_request_refresh() -class SmartTubSecondaryFiltrationCycle(SmartTubSensor): +class SmartTubSecondaryFiltrationCycle(SmartTubBuiltinSensor): """The secondary filtration cycle.""" def __init__( diff --git a/tests/components/smarttub/conftest.py b/tests/components/smarttub/conftest.py index 06780f8fb1e..f7677100aad 100644 --- a/tests/components/smarttub/conftest.py +++ b/tests/components/smarttub/conftest.py @@ -81,6 +81,16 @@ def mock_spa(spa_state): spa_state.lights = [mock_light_off, mock_light_on] + mock_cover_sensor = create_autospec(smarttub.SpaSensor, instance=True) + mock_cover_sensor.spa = mock_spa + mock_cover_sensor.address = "address1" + mock_cover_sensor.name = "{cover-sensor-1}" + mock_cover_sensor.type = "ibs0x" + mock_cover_sensor.subType = "magnet" + mock_cover_sensor.magnet = True # closed + + spa_state.sensors = [mock_cover_sensor] + mock_filter_reminder = create_autospec(smarttub.SpaReminder, instance=True) mock_filter_reminder.id = "FILTER01" mock_filter_reminder.name = "MyFilter" @@ -127,6 +137,7 @@ def mock_spa_state(): "cleanupCycle": "INACTIVE", "lights": [], "pumps": [], + "sensors": [], }, ) diff --git a/tests/components/smarttub/test_binary_sensor.py b/tests/components/smarttub/test_binary_sensor.py index 3365b03b041..cf5676aa0bb 100644 --- a/tests/components/smarttub/test_binary_sensor.py +++ b/tests/components/smarttub/test_binary_sensor.py @@ -104,3 +104,14 @@ async def test_reset_reminder(spa, setup_entry, hass: HomeAssistant) -> None: ) reminder.reset.assert_called_with(days) + + +async def test_cover_sensor(hass: HomeAssistant, spa, setup_entry) -> None: + """Test cover sensor.""" + + entity_id = f"binary_sensor.{spa.brand}_{spa.model}_cover_sensor_1" + + state = hass.states.get(entity_id) + assert state is not None + + assert state.state == STATE_OFF # closed From 971bd56bee65257704948b0ab770e917fbd1a1e5 Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 25 Jul 2025 20:37:36 +0200 Subject: [PATCH 152/235] Add Z-Box Hub virtual integration (#146678) --- homeassistant/components/zbox_hub/__init__.py | 1 + homeassistant/components/zbox_hub/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/zbox_hub/__init__.py create mode 100644 homeassistant/components/zbox_hub/manifest.json diff --git a/homeassistant/components/zbox_hub/__init__.py b/homeassistant/components/zbox_hub/__init__.py new file mode 100644 index 00000000000..4635546852c --- /dev/null +++ b/homeassistant/components/zbox_hub/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Z-Box Hub.""" diff --git a/homeassistant/components/zbox_hub/manifest.json b/homeassistant/components/zbox_hub/manifest.json new file mode 100644 index 00000000000..b3aa28e9af8 --- /dev/null +++ b/homeassistant/components/zbox_hub/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "zbox_hub", + "name": "Z-Box Hub", + "integration_type": "virtual", + "supported_by": "fibaro" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 33cc637b8a8..24f72add2ec 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7660,6 +7660,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "zbox_hub": { + "name": "Z-Box Hub", + "integration_type": "virtual", + "supported_by": "fibaro" + }, "zengge": { "name": "Zengge", "integration_type": "hub", From 56fb59e48ed12f4dcfb61325ebbedd3d2aa0c557 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 25 Jul 2025 21:21:57 +0200 Subject: [PATCH 153/235] Unifiprotect refactor device description ID retrieval in tests (#149445) --- .../unifiprotect/test_binary_sensor.py | 52 ++++++------- tests/components/unifiprotect/test_event.py | 36 ++++----- tests/components/unifiprotect/test_number.py | 24 ++++-- .../components/unifiprotect/test_recorder.py | 4 +- tests/components/unifiprotect/test_select.py | 60 +++++++------- tests/components/unifiprotect/test_sensor.py | 78 +++++++++++-------- tests/components/unifiprotect/test_switch.py | 36 ++++++--- tests/components/unifiprotect/test_text.py | 8 +- tests/components/unifiprotect/utils.py | 34 +++++++- 9 files changed, 198 insertions(+), 134 deletions(-) diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3aa441659b0..0c4d6e00066 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -111,8 +111,8 @@ async def test_binary_sensor_setup_light( assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) for description in LIGHT_SENSOR_WRITE: - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, description ) entity = entity_registry.async_get(entity_id) @@ -139,8 +139,8 @@ async def test_binary_sensor_setup_camera_all( assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 6) description = EVENT_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -154,8 +154,8 @@ async def test_binary_sensor_setup_camera_all( # Is Dark description = CAMERA_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -169,8 +169,8 @@ async def test_binary_sensor_setup_camera_all( # Motion description = EVENT_SENSORS[1] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -197,8 +197,8 @@ async def test_binary_sensor_setup_camera_none( description = CAMERA_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, camera, description ) entity = entity_registry.async_get(entity_id) @@ -229,8 +229,8 @@ async def test_binary_sensor_setup_sensor( STATE_OFF, ] for index, description in enumerate(SENSE_SENSORS_WRITE): - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -262,8 +262,8 @@ async def test_binary_sensor_setup_sensor_leak( STATE_OFF, ] for index, description in enumerate(SENSE_SENSORS_WRITE): - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -288,8 +288,8 @@ async def test_binary_sensor_update_motion( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 12) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] ) event = Event( @@ -334,8 +334,8 @@ async def test_binary_sensor_update_light_motion( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) event_metadata = EventMetadata(light_id=light.id) @@ -378,8 +378,8 @@ async def test_binary_sensor_update_mount_type_window( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -410,8 +410,8 @@ async def test_binary_sensor_update_mount_type_garage( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -451,8 +451,8 @@ async def test_binary_sensor_package_detected( doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PACKAGE) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[6] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[6] ) event = Event( @@ -592,8 +592,8 @@ async def test_binary_sensor_person_detected( doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] ) events = async_capture_events(hass, EVENT_STATE_CHANGED) diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index 032a3b253a7..80b11c047cc 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -57,8 +57,8 @@ async def test_doorbell_ring( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] ) unsub = async_track_state_change_event(hass, entity_id, _capture_event) @@ -171,8 +171,8 @@ async def test_doorbell_nfc_scanned( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -246,8 +246,8 @@ async def test_doorbell_nfc_scanned_ulpusr_deactivated( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -322,8 +322,8 @@ async def test_doorbell_nfc_scanned_no_ulpusr( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -390,8 +390,8 @@ async def test_doorbell_nfc_scanned_no_keyring( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) test_nfc_id = "test_nfc_id" @@ -451,8 +451,8 @@ async def test_doorbell_fingerprint_identified( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -519,8 +519,8 @@ async def test_doorbell_fingerprint_identified_user_deactivated( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -588,8 +588,8 @@ async def test_doorbell_fingerprint_identified_no_user( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -649,8 +649,8 @@ async def test_doorbell_fingerprint_not_identified( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) unsub = async_track_state_change_event(hass, entity_id, _capture_event) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 1838a574bc4..a93c49a2ebe 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -80,8 +80,8 @@ async def test_number_setup_light( assert_entity_counts(hass, Platform.NUMBER, 2, 2) for description in LIGHT_NUMBERS: - unique_id, entity_id = ids_from_device_description( - Platform.NUMBER, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description ) entity = entity_registry.async_get(entity_id) @@ -111,8 +111,8 @@ async def test_number_setup_camera_all( assert_entity_counts(hass, Platform.NUMBER, 5, 5) for description in CAMERA_NUMBERS: - unique_id, entity_id = ids_from_device_description( - Platform.NUMBER, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, description ) entity = entity_registry.async_get(entity_id) @@ -165,7 +165,9 @@ async def test_number_light_sensitivity( light.__pydantic_fields__["set_sensitivity"] = Mock(final=False, frozen=False) light.set_sensitivity = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True @@ -187,7 +189,9 @@ async def test_number_light_duration( light.__pydantic_fields__["set_duration"] = Mock(final=False, frozen=False) light.set_duration = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True @@ -215,7 +219,9 @@ async def test_number_camera_simple( ) setattr(camera, description.ufp_set_method, AsyncMock()) - _, entity_id = ids_from_device_description(Platform.NUMBER, camera, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 1.0}, blocking=True @@ -237,7 +243,9 @@ async def test_number_lock_auto_close( ) doorlock.set_auto_close_time = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, doorlock, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, doorlock, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 1f025a63306..c1eef3f7839 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -35,8 +35,8 @@ async def test_exclude_attributes( now = fixed_now await init_entry(hass, ufp, [doorbell, unadopted_camera]) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] ) event = Event( diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 6db3ae22dcb..f8485e678a1 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -98,8 +98,8 @@ async def test_select_setup_light( expected_values = ("On Motion - When Dark", "Not Paired") for index, description in enumerate(LIGHT_SELECTS): - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, description ) entity = entity_registry.async_get(entity_id) @@ -127,8 +127,8 @@ async def test_select_setup_viewer( description = VIEWER_SELECTS[0] - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, viewer, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, description ) entity = entity_registry.async_get(entity_id) @@ -161,8 +161,8 @@ async def test_select_setup_camera_all( ) for index, description in enumerate(CAMERA_SELECTS): - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -192,8 +192,8 @@ async def test_select_setup_camera_none( if index == 2: return - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, camera, description ) entity = entity_registry.async_get(entity_id) @@ -215,8 +215,8 @@ async def test_select_update_liveview( await init_entry(hass, ufp, [viewer]) assert_entity_counts(hass, Platform.SELECT, 1, 1) - _, entity_id = ids_from_device_description( - Platform.SELECT, viewer, VIEWER_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, VIEWER_SELECTS[0] ) state = hass.states.get(entity_id) @@ -252,8 +252,8 @@ async def test_select_update_doorbell_settings( expected_length = len(ufp.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -296,8 +296,8 @@ async def test_select_update_doorbell_message( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -330,7 +330,9 @@ async def test_select_set_option_light_motion( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) - _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) light.__pydantic_fields__["set_light_settings"] = Mock(final=False, frozen=False) light.set_light_settings = AsyncMock() @@ -355,7 +357,9 @@ async def test_select_set_option_light_camera( await init_entry(hass, ufp, [light, camera]) assert_entity_counts(hass, Platform.SELECT, 4, 4) - _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[1] + ) light.__pydantic_fields__["set_paired_camera"] = Mock(final=False, frozen=False) light.set_paired_camera = AsyncMock() @@ -389,8 +393,8 @@ async def test_select_set_option_camera_recording( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[0] ) doorbell.__pydantic_fields__["set_recording_mode"] = Mock(final=False, frozen=False) @@ -414,8 +418,8 @@ async def test_select_set_option_camera_ir( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) doorbell.__pydantic_fields__["set_ir_led_model"] = Mock(final=False, frozen=False) @@ -439,8 +443,8 @@ async def test_select_set_option_camera_doorbell_custom( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -466,8 +470,8 @@ async def test_select_set_option_camera_doorbell_unifi( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -508,8 +512,8 @@ async def test_select_set_option_camera_doorbell_default( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -537,8 +541,8 @@ async def test_select_set_option_viewer( await init_entry(hass, ufp, [viewer]) assert_entity_counts(hass, Platform.SELECT, 1, 1) - _, entity_id = ids_from_device_description( - Platform.SELECT, viewer, VIEWER_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, VIEWER_SELECTS[0] ) viewer.__pydantic_fields__["set_liveview"] = Mock(final=False, frozen=False) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index c65b3ac8e4e..a5c6d437006 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -119,8 +119,8 @@ async def test_sensor_setup_sensor( for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -133,7 +133,8 @@ async def test_sensor_setup_sensor( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION # BLE signal - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, get_sensor_by_key(ALL_DEVICES_SENSORS, "ble_signal"), @@ -173,8 +174,8 @@ async def test_sensor_setup_sensor_none( for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -228,8 +229,8 @@ async def test_sensor_setup_nvr( "50", ) for index, description in enumerate(NVR_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -247,8 +248,8 @@ async def test_sensor_setup_nvr( expected_values = ("50.0", "50.0", "50.0") for index, description in enumerate(NVR_DISABLED_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -283,8 +284,8 @@ async def test_sensor_nvr_missing_values( # Uptime description = get_sensor_by_key(NVR_SENSORS, "uptime") - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -300,8 +301,8 @@ async def test_sensor_nvr_missing_values( # Recording capacity description = get_sensor_by_key(NVR_SENSORS, "record_capacity") - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -315,8 +316,8 @@ async def test_sensor_nvr_missing_values( # Memory utilization description = get_sensor_by_key(NVR_DISABLED_SENSORS, "memory_utilization") - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -353,8 +354,8 @@ async def test_sensor_setup_camera( for index, description in enumerate(CAMERA_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -369,8 +370,8 @@ async def test_sensor_setup_camera( expected_values = ("0.0001", "0.0001") for index, description in enumerate(CAMERA_DISABLED_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -386,8 +387,11 @@ async def test_sensor_setup_camera( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION # Wired signal (phy_rate / link speed) - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, get_sensor_by_key(ALL_DEVICES_SENSORS, "phy_rate") + unique_id, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + doorbell, + get_sensor_by_key(ALL_DEVICES_SENSORS, "phy_rate"), ) entity = entity_registry.async_get(entity_id) @@ -403,7 +407,8 @@ async def test_sensor_setup_camera( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION # WiFi signal - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, get_sensor_by_key(ALL_DEVICES_SENSORS, "wifi_signal"), @@ -436,7 +441,8 @@ async def test_sensor_setup_camera_with_last_trip_time( assert_entity_counts(hass, Platform.SENSOR, 24, 24) # Last Trip Time - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, get_sensor_by_key(MOTION_TRIP_SENSORS, "motion_last_trip_time"), @@ -463,8 +469,11 @@ async def test_sensor_update_alarm( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) - _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, get_sensor_by_key(SENSE_SENSORS, "alarm_sound") + _, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "alarm_sound"), ) event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") @@ -514,7 +523,8 @@ async def test_sensor_update_alarm_with_last_trip_time( assert_entity_counts(hass, Platform.SENSOR, 22, 22) # Last Trip Time - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, get_sensor_by_key(SENSE_SENSORS, "door_last_trip_time"), @@ -547,7 +557,8 @@ async def test_camera_update_license_plate( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -664,7 +675,8 @@ async def test_camera_update_license_plate_changes_number_during_detect( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -753,7 +765,8 @@ async def test_camera_update_license_plate_multiple_updates( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -878,7 +891,8 @@ async def test_camera_update_license_no_dupes( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -973,8 +987,8 @@ async def test_sensor_precision( assert_entity_counts(hass, Platform.SENSOR, 22, 14) nvr: NVR = ufp.api.bootstrap.nvr - _, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, get_sensor_by_key(NVR_SENSORS, "resolution_4K") + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, get_sensor_by_key(NVR_SENSORS, "resolution_4K") ) assert hass.states.get(entity_id).state == "17.49" diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 1a899550204..501418948c6 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -135,8 +135,8 @@ async def test_switch_setup_light( description = LIGHT_SWITCHES[1] - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description ) entity = entity_registry.async_get(entity_id) @@ -178,8 +178,8 @@ async def test_switch_setup_camera_all( assert_entity_counts(hass, Platform.SWITCH, 17, 15) for description in CAMERA_SWITCHES_BASIC: - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -224,8 +224,8 @@ async def test_switch_setup_camera_none( if description.ufp_required_field is not None: continue - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, camera, description ) entity = entity_registry.async_get(entity_id) @@ -268,7 +268,9 @@ async def test_switch_light_status( light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False) light.set_status_light = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -296,7 +298,9 @@ async def test_switch_camera_ssh( doorbell.__pydantic_fields__["set_ssh"] = Mock(final=False, frozen=False) doorbell.set_ssh = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await enable_entity(hass, ufp.entry.entry_id, entity_id) await hass.services.async_call( @@ -332,7 +336,9 @@ async def test_switch_camera_simple( setattr(doorbell, description.ufp_set_method, AsyncMock()) set_method = getattr(doorbell, description.ufp_set_method) - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -360,7 +366,9 @@ async def test_switch_camera_highfps( doorbell.__pydantic_fields__["set_video_mode"] = Mock(final=False, frozen=False) doorbell.set_video_mode = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -391,7 +399,9 @@ async def test_switch_camera_privacy( doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) state = hass.states.get(entity_id) assert state and state.state == "off" @@ -443,7 +453,9 @@ async def test_switch_camera_privacy_already_on( doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index c34611c43a9..99f16fcbb75 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -51,8 +51,8 @@ async def test_text_camera_setup( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = ids_from_device_description( - Platform.TEXT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.TEXT, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -74,8 +74,8 @@ async def test_text_camera_set( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = ids_from_device_description( - Platform.TEXT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.TEXT, doorbell, description ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index ddd6fdf0189..6514f672d90 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -23,7 +23,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from homeassistant.helpers.entity import EntityDescription from homeassistant.util import dt as dt_util @@ -100,17 +100,43 @@ def normalize_name(name: str) -> str: return name.lower().replace(":", "").replace(" ", "_").replace("-", "_") -def ids_from_device_description( +async def async_get_translated_entity_name( + hass: HomeAssistant, platform: Platform, translation_key: str +) -> str: + """Get the translated entity name for a given platform and translation key.""" + platform_name = "unifiprotect" + + # Get the translations for the UniFi Protect integration + translations = await translation.async_get_translations( + hass, "en", "entity", {platform_name} + ) + + # Build the translation key in the format that Home Assistant uses + # component.{integration}.entity.{platform}.{translation_key}.name + full_translation_key = ( + f"component.{platform_name}.entity.{platform.value}.{translation_key}.name" + ) + + # Get the translated name, fall back to the translation key if not found + return translations.get(full_translation_key, translation_key) + + +async def ids_from_device_description( + hass: HomeAssistant, platform: Platform, device: ProtectAdoptableDeviceModel, description: EntityDescription, ) -> tuple[str, str]: - """Return expected unique_id and entity_id for a give platform/device/description combination.""" + """Return expected unique_id and entity_id using real Home Assistant translation logic.""" entity_name = normalize_name(device.display_name) if getattr(description, "translation_key", None): - description_entity_name = normalize_name(description.translation_key) + # Get the actual translated name from Home Assistant + translated_name = await async_get_translated_entity_name( + hass, platform, description.translation_key + ) + description_entity_name = normalize_name(translated_name) elif getattr(description, "device_class", None): description_entity_name = normalize_name(description.device_class) else: From cbf4409db38070bac85cbeab0c79a0f95a96f737 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 21:51:01 +0200 Subject: [PATCH 154/235] Fix inconsistent spelling of "Wi-Fi" in `unifiprotect` (#149311) Co-authored-by: J. Nick Koston --- homeassistant/components/unifiprotect/strings.json | 2 +- tests/components/unifiprotect/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index f20b56d29e4..9289d0f66d4 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -357,7 +357,7 @@ "name": "Link speed" }, "wifi_signal_strength": { - "name": "WiFi signal strength" + "name": "Wi-Fi signal strength" }, "oldest_recording": { "name": "Oldest recording" diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index a5c6d437006..75193a491c9 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -406,7 +406,7 @@ async def test_sensor_setup_camera( assert state.state == "1000" assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # WiFi signal + # Wi-Fi signal unique_id, entity_id = await ids_from_device_description( hass, Platform.SENSOR, From aab7381553dbb5019521b25b98e280bde00b354f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 26 Jul 2025 00:27:04 +0200 Subject: [PATCH 155/235] Add test of ConfigSubentryFlow._subentry_type (#147565) --- tests/test_config_entries.py | 37 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 9666e8ba1c4..833d28ecdd9 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8008,7 +8008,10 @@ async def test_get_reconfigure_entry( async def test_subentry_get_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test subentry _get_entry and _get_reconfigure_subentry behavior.""" + """Test subentry _get_entry and _get_reconfigure_subentry behavior. + + Also tests related helpers _entry_id, _subentry_type, _reconfigure_subentry_id + """ subentry_id = "mock_subentry_id" entry = MockConfigEntry( data={}, @@ -8044,18 +8047,8 @@ async def test_subentry_get_entry( async def _async_step_confirm(self): """Confirm input.""" - try: - entry = self._get_entry() - except ValueError as err: - reason = str(err) - else: - reason = f"Found entry {entry.title}" - try: - entry_id = self._entry_id - except ValueError: - reason = f"{reason}: -" - else: - reason = f"{reason}: {entry_id}" + reason = f"Found entry {self._get_entry().title},{self._entry_id}: " + reason = f"{reason}subentry_type={self._subentry_type}" try: subentry = self._get_reconfigure_subentry() @@ -8083,9 +8076,9 @@ async def test_subentry_get_entry( # A reconfigure flow finds the config entry and subentry with mock_config_flow("test", TestFlow): result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Found subentry Test: mock_subentry_id" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Found subentry Test: mock_subentry_id" ) # The subentry_id does not exist @@ -8097,9 +8090,9 @@ async def test_subentry_get_entry( "subentry_id": "01JRemoved", }, ) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Subentry not found: 01JRemoved" ) # A user flow finds the config entry but not the subentry @@ -8107,9 +8100,9 @@ async def test_subentry_get_entry( result = await manager.subentries.async_init( (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} ) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Source is user, expected reconfigure: -" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Source is user, expected reconfigure: -" ) From e017dc80a0097705e77f429f7e8f984fb02cb778 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sat, 26 Jul 2025 01:07:51 +0200 Subject: [PATCH 156/235] Allow to reorder members within a group (#149003) --- homeassistant/components/group/config_flow.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index ee8d11d035d..5e36087e9e4 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -56,12 +56,12 @@ async def basic_group_options_schema( entity_selector: selector.Selector[Any] | vol.Schema if handler is None: entity_selector = selector.selector( - {"entity": {"domain": domain, "multiple": True}} + {"entity": {"domain": domain, "multiple": True, "reorder": True}} ) else: entity_selector = entity_selector_without_own_entities( cast(SchemaOptionsFlowHandler, handler.parent_handler), - selector.EntitySelectorConfig(domain=domain, multiple=True), + selector.EntitySelectorConfig(domain=domain, multiple=True, reorder=True), ) return vol.Schema( @@ -78,7 +78,9 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema: { vol.Required("name"): selector.TextSelector(), vol.Required(CONF_ENTITIES): selector.EntitySelector( - selector.EntitySelectorConfig(domain=domain, multiple=True), + selector.EntitySelectorConfig( + domain=domain, multiple=True, reorder=True + ), ), vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(), } From 002b7c6789717df71659e02c25850f7b5ac17a11 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 26 Jul 2025 09:47:26 +0200 Subject: [PATCH 157/235] Fix descriptions in `home_connect.set_program_and_options` action (#149462) --- homeassistant/components/home_connect/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 0b094a9d49a..fa24177a967 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -582,7 +582,7 @@ }, "consumer_products_cleaning_robot_option_cleaning_mode": { "name": "Cleaning mode", - "description": "Defines the favoured cleaning mode." + "description": "Defines the favored cleaning mode." }, "consumer_products_coffee_maker_option_bean_amount": { "name": "Bean amount", @@ -670,7 +670,7 @@ }, "cooking_oven_option_setpoint_temperature": { "name": "Setpoint temperature", - "description": "Defines the target cavity temperature, which will be hold by the oven." + "description": "Defines the target cavity temperature, which will be held by the oven." }, "b_s_h_common_option_duration": { "name": "Duration", From c5cf9b07b7fa5df4908cba537462f8303763877c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 26 Jul 2025 12:34:24 +0200 Subject: [PATCH 158/235] Replace HA alarm (control panel) states with references in `risco` (#149466) --- homeassistant/components/risco/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 86d131b4f80..22ed3ff4e52 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -45,7 +45,7 @@ }, "risco_to_ha": { "title": "Map Risco states to Home Assistant states", - "description": "Select what state your Home Assistant alarm will report for every state reported by Risco", + "description": "Select what state your Home Assistant alarm control panel will report for every state reported by Risco", "data": { "arm": "Armed (AWAY)", "partial_arm": "Partially Armed (STAY)", @@ -57,12 +57,12 @@ }, "ha_to_risco": { "title": "Map Home Assistant states to Risco states", - "description": "Select what state to set your Risco alarm to when arming the Home Assistant alarm", + "description": "Select what state to set your Risco alarm to when arming the Home Assistant alarm control panel", "data": { - "armed_away": "Armed Away", - "armed_home": "Armed Home", - "armed_night": "Armed Night", - "armed_custom_bypass": "Armed Custom Bypass" + "armed_away": "[%key:component::alarm_control_panel::entity_component::_::state::armed_away%]", + "armed_home": "[%key:component::alarm_control_panel::entity_component::_::state::armed_home%]", + "armed_night": "[%key:component::alarm_control_panel::entity_component::_::state::armed_night%]", + "armed_custom_bypass": "[%key:component::alarm_control_panel::entity_component::_::state::armed_custom_bypass%]" } } } From be5109fddf26cae70ff101e60568f9bfc59afb29 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 26 Jul 2025 12:35:11 +0200 Subject: [PATCH 159/235] Change spelling of "Favorite x" to intl. English in `bang_olufsen` (#149464) --- homeassistant/components/bang_olufsen/strings.json | 8 ++++---- tests/components/bang_olufsen/snapshots/test_event.ambr | 8 ++++---- tests/components/bang_olufsen/test_event.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 422dc4be567..bacd32fa77e 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -93,7 +93,7 @@ } }, "preset1": { - "name": "Favourite 1", + "name": "Favorite 1", "state_attributes": { "event_type": { "state": { @@ -107,7 +107,7 @@ } }, "preset2": { - "name": "Favourite 2", + "name": "Favorite 2", "state_attributes": { "event_type": { "state": { @@ -121,7 +121,7 @@ } }, "preset3": { - "name": "Favourite 3", + "name": "Favorite 3", "state_attributes": { "event_type": { "state": { @@ -135,7 +135,7 @@ } }, "preset4": { - "name": "Favourite 4", + "name": "Favorite 4", "state_attributes": { "event_type": { "state": { diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr index 3b748d3a27a..a7fc2c88e49 100644 --- a/tests/components/bang_olufsen/snapshots/test_event.ambr +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -5,10 +5,10 @@ 'event.beosound_balance_11111111_microphone', 'event.beosound_balance_11111111_next', 'event.beosound_balance_11111111_play_pause', - 'event.beosound_balance_11111111_favourite_1', - 'event.beosound_balance_11111111_favourite_2', - 'event.beosound_balance_11111111_favourite_3', - 'event.beosound_balance_11111111_favourite_4', + 'event.beosound_balance_11111111_favorite_1', + 'event.beosound_balance_11111111_favorite_2', + 'event.beosound_balance_11111111_favorite_3', + 'event.beosound_balance_11111111_favorite_4', 'event.beosound_balance_11111111_previous', 'event.beosound_balance_11111111_volume', 'media_player.beosound_balance_11111111', diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index 11f337b715f..1e5546ac5f2 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -32,7 +32,7 @@ async def test_button_event_creation( # Add Button Event entity ids entity_ids = [ f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "favourite_" + "preset", "favorite_" ) for button_type in DEVICE_BUTTONS ] From e1501d7510609e640f0daefe649c297a3c5427fa Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Sat, 26 Jul 2025 12:38:38 +0200 Subject: [PATCH 160/235] Bump pysuezV2 to 2.0.7 (#149436) --- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/suez_water/conftest.py | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 9149f216563..5c23240ce91 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.5"] + "requirements": ["pysuezV2==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index f308f39aa86..2229ee3e3a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2385,7 +2385,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daac6214663..5462957f60c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1988,7 +1988,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 9d29191289e..005c14b7458 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -87,5 +87,7 @@ def mock_suez_client(recorder_mock: Recorder) -> Generator[AsyncMock]: ) suez_client.fetch_aggregated_data.return_value = result - suez_client.get_price.return_value = PriceResult("4.74") + suez_client.get_price.return_value = PriceResult( + "OK", {"price": 4.74}, "Price is 4.74" + ) yield suez_client From 5aa0d0dc81b5270877d6f0746bb0653c225aee40 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 26 Jul 2025 14:32:51 +0300 Subject: [PATCH 161/235] Remove Shelly redundant device info assignment in Button class (#149469) --- homeassistant/components/shelly/button.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 2ab23441c98..bd42af002d3 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -19,7 +19,6 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -251,9 +250,6 @@ class ShellyButton(ShellyBaseButton): coordinator.model_name, suggested_area=coordinator.suggested_area, ) - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} - ) async def _press_method(self) -> None: """Press method.""" From 7976729e76f0903565af0873d54ab59db8780ea9 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Sat, 26 Jul 2025 14:19:33 +0200 Subject: [PATCH 162/235] Paperless-ngx: Retry setup on initialization error (#149476) --- homeassistant/components/paperless_ngx/__init__.py | 2 +- tests/components/paperless_ngx/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index 0fea90b7ea3..da990be7173 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -96,7 +96,7 @@ async def _get_paperless_api( translation_key="forbidden", ) from err except InitializationError as err: - raise ConfigEntryError( + raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", ) from err diff --git a/tests/components/paperless_ngx/test_init.py b/tests/components/paperless_ngx/test_init.py index fd459213ea0..924e3966c79 100644 --- a/tests/components/paperless_ngx/test_init.py +++ b/tests/components/paperless_ngx/test_init.py @@ -63,7 +63,7 @@ async def test_load_config_status_forbidden( "user_inactive_or_deleted", ), (PaperlessForbiddenError(), ConfigEntryState.SETUP_ERROR, "forbidden"), - (InitializationError(), ConfigEntryState.SETUP_ERROR, "cannot_connect"), + (InitializationError(), ConfigEntryState.SETUP_RETRY, "cannot_connect"), ], ) async def test_setup_config_error_handling( From b6bd92ed192ec17183d08284823c9d8b808bbc79 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 26 Jul 2025 17:08:08 +0300 Subject: [PATCH 163/235] Shelly entity device info code quality (#149477) --- homeassistant/components/shelly/button.py | 27 +------ homeassistant/components/shelly/climate.py | 13 +--- homeassistant/components/shelly/entity.py | 88 ++++++++++------------ homeassistant/components/shelly/event.py | 13 +--- homeassistant/components/shelly/sensor.py | 13 +--- 5 files changed, 52 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index bd42af002d3..209fa4af54a 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -25,13 +25,8 @@ from homeassistant.util import slugify from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import ( - get_block_device_info, - get_blu_trv_device_info, - get_device_entry_gen, - get_rpc_device_info, - get_rpc_key_ids, -) +from .entity import get_entity_block_device_info, get_entity_rpc_device_info +from .utils import get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_ids PARALLEL_UPDATES = 0 @@ -233,23 +228,9 @@ class ShellyButton(ShellyBaseButton): self._attr_unique_id = f"{coordinator.mac}_{description.key}" if isinstance(coordinator, ShellyBlockCoordinator): - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator) else: - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator) async def _press_method(self) -> None: """Press method.""" diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 2a09e867dce..3a495c9f4ac 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -38,10 +38,9 @@ from .const import ( SHTRV_01_TEMPERATURE_SETTINGS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyRpcEntity, rpc_call +from .entity import ShellyRpcEntity, get_entity_block_device_info, rpc_call from .utils import ( async_remove_shelly_entity, - get_block_device_info, get_block_entity_name, get_blu_trv_device_info, get_device_entry_gen, @@ -210,15 +209,7 @@ class BlockSleepingClimate( ] elif entry is not None: self._unique_id = entry.unique_id - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - sensor_block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, sensor_block) self._attr_name = get_block_entity_name( self.coordinator.device, sensor_block, None ) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 33a45a0e10f..97946ddd8f3 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -13,6 +13,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry @@ -368,15 +369,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): super().__init__(coordinator) self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, block) self._attr_unique_id = f"{coordinator.mac}-{block.description}" # pylint: disable-next=hass-missing-super-call @@ -417,15 +410,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -539,14 +524,7 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): coordinator.device, None, description.name ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator) self._last_value = None @property @@ -653,15 +631,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.block: Block | None = block # type: ignore[assignment] self.entity_description = description - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, block) if block is not None: self._attr_unique_id = ( @@ -726,18 +696,8 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.attribute = attribute self.entity_description = description - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - suggested_area=coordinator.suggested_area, - ) - self._attr_unique_id = self._attr_unique_id = ( - f"{coordinator.mac}-{key}-{attribute}" - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) + self._attr_unique_id = f"{coordinator.mac}-{key}-{attribute}" self._last_value = None if coordinator.device.initialized: @@ -763,3 +723,37 @@ def get_entity_class( return description.entity_class return sensor_class + + +def get_entity_block_device_info( + coordinator: ShellyBlockCoordinator, + block: Block | None = None, +) -> DeviceInfo: + """Get device info for block entities.""" + return get_block_device_info( + coordinator.device, + coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, + block, + suggested_area=coordinator.suggested_area, + ) + + +def get_entity_rpc_device_info( + coordinator: ShellyRpcCoordinator, + key: str | None = None, + emeter_phase: str | None = None, +) -> DeviceInfo: + """Get device info for RPC entities.""" + return get_rpc_device_info( + coordinator.device, + coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, + key, + emeter_phase=emeter_phase, + suggested_area=coordinator.suggested_area, + ) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 9e1c748c790..8b2b92e11ce 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -26,12 +26,11 @@ from .const import ( SHIX3_1_INPUTS_EVENTS_TYPES, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyBlockEntity +from .entity import ShellyBlockEntity, get_entity_rpc_device_info from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, - get_rpc_device_info, get_rpc_entity_name, get_rpc_key_instances, is_block_momentary_input, @@ -206,15 +205,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Initialize Shelly entity.""" super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) self.entity_description = description diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index cdfa97357f2..49e3d4773c7 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -52,13 +52,13 @@ from .entity import ( async_setup_entry_attribute_entities, async_setup_entry_rest, async_setup_entry_rpc, + get_entity_rpc_device_info, ) from .utils import ( async_remove_orphaned_entities, get_blu_trv_device_info, get_device_entry_gen, get_device_uptime, - get_rpc_device_info, get_shelly_air_lamp_life, get_virtual_component_ids, is_rpc_wifi_stations_disabled, @@ -138,15 +138,8 @@ class RpcEmeterPhaseSensor(RpcSensor): """Initialize select.""" super().__init__(coordinator, key, attribute, description) - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - emeter_phase=description.emeter_phase, - suggested_area=coordinator.suggested_area, + self._attr_device_info = get_entity_rpc_device_info( + coordinator, key, emeter_phase=description.emeter_phase ) From 427e5d81dfd180bcb5031d1c7468981620ae3eaa Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Sat, 26 Jul 2025 19:03:51 +0300 Subject: [PATCH 164/235] Bump pyituran to 0.1.5 (#149486) --- homeassistant/components/ituran/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ituran/manifest.json b/homeassistant/components/ituran/manifest.json index 0cf20d3c6b2..d63ca2fef84 100644 --- a/homeassistant/components/ituran/manifest.json +++ b/homeassistant/components/ituran/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["pyituran==0.1.4"] + "requirements": ["pyituran==0.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2229ee3e3a0..b20cdaa61c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2068,7 +2068,7 @@ pyisy==3.4.1 pyitachip2ir==0.0.7 # homeassistant.components.ituran -pyituran==0.1.4 +pyituran==0.1.5 # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5462957f60c..a9d22851d43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1719,7 +1719,7 @@ pyiss==1.0.1 pyisy==3.4.1 # homeassistant.components.ituran -pyituran==0.1.4 +pyituran==0.1.5 # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 From 27bd6d2e385df953205a70b120be7b7b3af14c7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 22:48:48 -1000 Subject: [PATCH 165/235] Bump aioesphomeapi to 37.1.2 (#149460) --- 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 e83ab16064c..17fd72fc939 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==37.0.2", + "aioesphomeapi==37.1.2", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b20cdaa61c6..4f873d3a9be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.0.2 +aioesphomeapi==37.1.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9d22851d43..d6c85395d5b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.0.2 +aioesphomeapi==37.1.2 # homeassistant.components.flo aioflo==2021.11.0 From 57b641b97d908490bf1643682fa445e623f35f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sun, 27 Jul 2025 11:43:48 +0100 Subject: [PATCH 166/235] Use non-autospec mock in Reolink's media source, number, sensor and siren tests (#149396) --- tests/components/reolink/conftest.py | 7 +++ tests/components/reolink/test_media_source.py | 58 +++++++++---------- tests/components/reolink/test_number.py | 51 +++++++--------- tests/components/reolink/test_sensor.py | 22 +++---- tests/components/reolink/test_siren.py | 26 ++++----- 5 files changed, 77 insertions(+), 87 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a5f528edef6..d699d1b9102 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -67,6 +67,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.get_host_data = AsyncMock(return_value=None) host_mock.get_states = AsyncMock(return_value=None) host_mock.get_state = AsyncMock() + host_mock.async_get_time = AsyncMock() host_mock.check_new_firmware = AsyncMock(return_value=False) host_mock.subscribe = AsyncMock() host_mock.unsubscribe = AsyncMock(return_value=True) @@ -80,12 +81,16 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.pull_point_request = AsyncMock() host_mock.set_audio = AsyncMock() host_mock.set_email = AsyncMock() + host_mock.set_siren = AsyncMock() host_mock.ONVIF_event_callback = AsyncMock() host_mock.set_whiteled = AsyncMock() host_mock.set_state_light = AsyncMock() host_mock.renew = AsyncMock() host_mock.get_vod_source = AsyncMock() + host_mock.request_vod_files = AsyncMock() host_mock.expire_session = AsyncMock() + host_mock.set_volume = AsyncMock() + host_mock.set_hub_audio = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -168,6 +173,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: 0: {"chnID": 0, "aitype": 34615}, "Host": {"pushAlarm": 7}, } + host_mock.baichuan.set_smart_ai = AsyncMock() host_mock.baichuan.smart_location_list.return_value = [0] host_mock.baichuan.smart_ai_type_list.return_value = ["people"] host_mock.baichuan.smart_ai_index.return_value = 1 @@ -281,6 +287,7 @@ def reolink_chime(reolink_host: MagicMock) -> None: "visitor": {"switch": 1, "musicId": 2}, } TEST_CHIME.remove = AsyncMock() + TEST_CHIME.set_option = AsyncMock() reolink_host.chime_list = [TEST_CHIME] reolink_host.chime.return_value = TEST_CHIME diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 31da3b213be..0308639499c 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -89,7 +89,7 @@ async def test_platform_loads_before_config_entry( async def test_resolve( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: @@ -99,7 +99,7 @@ async def test_resolve( caplog.set_level(logging.DEBUG) file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -107,14 +107,14 @@ async def test_resolve( assert play_media.mime_type == TEST_MIME_TYPE_MP4 file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME_MP4}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None ) assert play_media.mime_type == TEST_MIME_TYPE_MP4 - reolink_connect.is_nvr = False + reolink_host.is_nvr = False play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -122,7 +122,7 @@ async def test_resolve( assert play_media.mime_type == TEST_MIME_TYPE_MP4 file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -132,16 +132,16 @@ async def test_resolve( async def test_browsing( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test browsing the Reolink three.""" entry_id = config_entry.entry_id - reolink_connect.supported.return_value = 1 - reolink_connect.model = "Reolink TrackMix PoE" - reolink_connect.is_nvr = False + reolink_host.supported.return_value = 1 + reolink_host.model = "Reolink TrackMix PoE" + reolink_host.is_nvr = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True @@ -184,7 +184,7 @@ async def test_browsing( mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH mock_status.days = (TEST_DAY, TEST_DAY2) - reolink_connect.request_vod_files.return_value = ([mock_status], []) + reolink_host.request_vod_files.return_value = ([mock_status], []) browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") assert browse.domain == DOMAIN @@ -223,7 +223,7 @@ async def test_browsing( mock_vod_file.duration = timedelta(minutes=5) mock_vod_file.file_name = TEST_FILE_NAME mock_vod_file.triggers = VOD_trigger.PERSON - reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file]) + reolink_host.request_vod_files.return_value = ([mock_status], [mock_vod_file]) browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") @@ -236,7 +236,7 @@ async def test_browsing( ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id - reolink_connect.request_vod_files.assert_called_with( + reolink_host.request_vod_files.assert_called_with( int(TEST_CHANNEL), TEST_START_TIME, TEST_END_TIME, @@ -245,10 +245,10 @@ async def test_browsing( trigger=None, ) - reolink_connect.model = TEST_HOST_MODEL + reolink_host.model = TEST_HOST_MODEL # browse event trigger person on a NVR - reolink_connect.is_nvr = True + reolink_host.is_nvr = True browse_event_person_id = f"EVE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}|{VOD_trigger.PERSON.name}" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") @@ -265,7 +265,7 @@ async def test_browsing( ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id - reolink_connect.request_vod_files.assert_called_with( + reolink_host.request_vod_files.assert_called_with( int(TEST_CHANNEL), TEST_START_TIME, TEST_END_TIME, @@ -274,17 +274,15 @@ async def test_browsing( trigger=VOD_trigger.PERSON, ) - reolink_connect.is_nvr = False - async def test_browsing_h265_encoding( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera with h265 stream encoding.""" entry_id = config_entry.entry_id - reolink_connect.is_nvr = True + reolink_host.is_nvr = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True @@ -296,10 +294,10 @@ async def test_browsing_h265_encoding( mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH mock_status.days = (TEST_DAY, TEST_DAY2) - reolink_connect.request_vod_files.return_value = ([mock_status], []) - reolink_connect.time.return_value = None - reolink_connect.get_encoding.return_value = "h265" - reolink_connect.supported.return_value = False + reolink_host.request_vod_files.return_value = ([mock_status], []) + reolink_host.time.return_value = None + reolink_host.get_encoding.return_value = "h265" + reolink_host.supported.return_value = False browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") @@ -331,7 +329,7 @@ async def test_browsing_h265_encoding( async def test_browsing_rec_playback_unsupported( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera which does not support playback of recordings.""" @@ -342,7 +340,7 @@ async def test_browsing_rec_playback_unsupported( return False return True - reolink_connect.supported = test_supported + reolink_host.supported = test_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -356,12 +354,10 @@ async def test_browsing_rec_playback_unsupported( assert browse.identifier is None assert browse.children == [] - reolink_connect.supported = lambda ch, key: True # Reset supported function - async def test_browsing_errors( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera errors.""" @@ -378,7 +374,7 @@ async def test_browsing_errors( async def test_browsing_not_loaded( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera integration which is not loaded.""" @@ -386,7 +382,7 @@ async def test_browsing_not_loaded( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + reolink_host.get_host_data.side_effect = ReolinkError("Test error") config_entry2 = MockConfigEntry( domain=DOMAIN, unique_id=format_mac(TEST_MAC2), @@ -414,5 +410,3 @@ async def test_browsing_not_loaded( assert browse.title == "Reolink" assert browse.identifier is None assert len(browse.children) == 1 - - reolink_connect.get_host_data.side_effect = None diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index dd70376d658..17fc2797479 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -1,6 +1,6 @@ """Test the Reolink number platform.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from reolink_aio.api import Chime @@ -24,10 +24,10 @@ from tests.common import MockConfigEntry async def test_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with volume.""" - reolink_connect.volume.return_value = 80 + reolink_host.volume.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -44,9 +44,9 @@ async def test_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, blocking=True, ) - reolink_connect.set_volume.assert_called_with(0, volume=50) + reolink_host.set_volume.assert_called_with(0, volume=50) - reolink_connect.set_volume.side_effect = ReolinkError("Test error") + reolink_host.set_volume.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -55,7 +55,7 @@ async def test_number( blocking=True, ) - reolink_connect.set_volume.side_effect = InvalidParameterError("Test error") + reolink_host.set_volume.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -64,17 +64,15 @@ async def test_number( blocking=True, ) - reolink_connect.set_volume.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_smart_ai_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with smart ai sensitivity.""" - reolink_connect.baichuan.smart_ai_sensitivity.return_value = 80 + reolink_host.baichuan.smart_ai_sensitivity.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -91,13 +89,11 @@ async def test_smart_ai_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, blocking=True, ) - reolink_connect.baichuan.set_smart_ai.assert_called_with( + reolink_host.baichuan.set_smart_ai.assert_called_with( 0, "crossline", 0, sensitivity=50 ) - reolink_connect.baichuan.set_smart_ai.side_effect = InvalidParameterError( - "Test error" - ) + reolink_host.baichuan.set_smart_ai.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -106,16 +102,14 @@ async def test_smart_ai_number( blocking=True, ) - reolink_connect.baichuan.set_smart_ai.reset_mock(side_effect=True) - async def test_host_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with volume.""" - reolink_connect.alarm_volume = 85 + reolink_host.alarm_volume = 85 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -132,9 +126,9 @@ async def test_host_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, blocking=True, ) - reolink_connect.set_hub_audio.assert_called_with(alarm_volume=45) + reolink_host.set_hub_audio.assert_called_with(alarm_volume=45) - reolink_connect.set_hub_audio.side_effect = ReolinkError("Test error") + reolink_host.set_hub_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -143,7 +137,7 @@ async def test_host_number( blocking=True, ) - reolink_connect.set_hub_audio.side_effect = InvalidParameterError("Test error") + reolink_host.set_hub_audio.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -156,11 +150,11 @@ async def test_host_number( async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, ) -> None: """Test number entity of a chime with chime volume.""" - test_chime.volume = 3 + reolink_chime.volume = 3 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -171,16 +165,15 @@ async def test_chime_number( assert hass.states.get(entity_id).state == "3" - test_chime.set_option = AsyncMock() await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 2}, blocking=True, ) - test_chime.set_option.assert_called_with(volume=2) + reolink_chime.set_option.assert_called_with(volume=2) - test_chime.set_option.side_effect = ReolinkError("Test error") + reolink_chime.set_option.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -189,7 +182,7 @@ async def test_chime_number( blocking=True, ) - test_chime.set_option.side_effect = InvalidParameterError("Test error") + reolink_chime.set_option.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -197,5 +190,3 @@ async def test_chime_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 1}, blocking=True, ) - - test_chime.set_option.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index c3fe8d89951..b30f0c2a61a 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -17,14 +17,14 @@ from tests.common import MockConfigEntry async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test sensor entities.""" - reolink_connect.ptz_pan_position.return_value = 1200 - reolink_connect.wifi_connection = True - reolink_connect.wifi_signal.return_value = -55 - reolink_connect.hdd_list = [0] - reolink_connect.hdd_storage.return_value = 95 + reolink_host.ptz_pan_position.return_value = 1200 + reolink_host.wifi_connection = True + reolink_host.wifi_signal.return_value = -55 + reolink_host.hdd_list = [0] + reolink_host.hdd_storage.return_value = 95 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -45,13 +45,13 @@ async def test_sensors( async def test_hdd_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test hdd sensor entity.""" - reolink_connect.hdd_list = [0] - reolink_connect.hdd_type.return_value = "HDD" - reolink_connect.hdd_storage.return_value = 85 - reolink_connect.hdd_available.return_value = False + reolink_host.hdd_list = [0] + reolink_host.hdd_type.return_value = "HDD" + reolink_host.hdd_storage.return_value = 85 + reolink_host.hdd_available.return_value = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index f6ba8e0ea77..43156626b12 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -30,7 +30,7 @@ from tests.common import MockConfigEntry async def test_siren( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test siren entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SIREN]): @@ -48,8 +48,8 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_volume.assert_not_called() - reolink_connect.set_siren.assert_called_with(0, True, None) + reolink_host.set_volume.assert_not_called() + reolink_host.set_siren.assert_called_with(0, True, None) await hass.services.async_call( SIREN_DOMAIN, @@ -57,8 +57,8 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id, ATTR_VOLUME_LEVEL: 0.85, ATTR_DURATION: 2}, blocking=True, ) - reolink_connect.set_volume.assert_called_with(0, volume=85) - reolink_connect.set_siren.assert_called_with(0, True, 2) + reolink_host.set_volume.assert_called_with(0, 85) + reolink_host.set_siren.assert_called_with(0, True, 2) # test siren turn off await hass.services.async_call( @@ -67,7 +67,7 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_siren.assert_called_with(0, False, None) + reolink_host.set_siren.assert_called_with(0, False, None) @pytest.mark.parametrize("attr", ["set_volume", "set_siren"]) @@ -87,7 +87,7 @@ async def test_siren( async def test_siren_turn_on_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, attr: str, value: Any, expected: Any, @@ -100,8 +100,8 @@ async def test_siren_turn_on_errors( entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + original = getattr(reolink_host, attr) + setattr(reolink_host, attr, value) with pytest.raises(expected): await hass.services.async_call( SIREN_DOMAIN, @@ -110,13 +110,13 @@ async def test_siren_turn_on_errors( blocking=True, ) - setattr(reolink_connect, attr, original) + setattr(reolink_host, attr, original) async def test_siren_turn_off_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors when calling siren turn off service.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SIREN]): @@ -126,7 +126,7 @@ async def test_siren_turn_off_errors( entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" - reolink_connect.set_siren.side_effect = ReolinkError("Test error") + reolink_host.set_siren.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SIREN_DOMAIN, @@ -134,5 +134,3 @@ async def test_siren_turn_off_errors( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - reolink_connect.set_siren.reset_mock(side_effect=True) From 22d0fbcbd2a33dfff9a274bdccf5bfadeefff54a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 27 Jul 2025 14:39:21 +0200 Subject: [PATCH 167/235] Fix spelling of "its" in `mqtt` (#149517) --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ba869a7334b..92900d8292d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -136,7 +136,7 @@ "step": { "availability": { "title": "Availability options", - "description": "The availability feature allows a device to report it's availability.", + "description": "The availability feature allows a device to report its availability.", "data": { "availability_topic": "Availability topic", "availability_template": "Availability template", From 0e9ced3c00074c1adecbbcafa9ba6274ae56aa2f Mon Sep 17 00:00:00 2001 From: petep0p Date: Sun, 27 Jul 2025 06:13:31 -0700 Subject: [PATCH 168/235] Correct core Purpleair integration's RSSI sensor to use RSSI value rather than barometric pressure (#149418) --- homeassistant/components/purpleair/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index a85a23b6144..3a2e42e63cb 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -132,7 +132,7 @@ SENSOR_DESCRIPTIONS = [ entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda sensor: sensor.pressure, + value_fn=lambda sensor: sensor.rssi, ), PurpleAirSensorEntityDescription( key="temperature", From dac75d19026f5202c5268d0a646091272d856156 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 27 Jul 2025 18:02:33 +0200 Subject: [PATCH 169/235] Add update platform to Uptime Kuma (#148973) --- .../components/uptime_kuma/__init__.py | 32 ++++- .../components/uptime_kuma/coordinator.py | 32 ++++- .../components/uptime_kuma/strings.json | 8 ++ .../components/uptime_kuma/update.py | 122 ++++++++++++++++++ tests/components/uptime_kuma/conftest.py | 20 +++ .../uptime_kuma/snapshots/test_update.ambr | 61 +++++++++ tests/components/uptime_kuma/test_update.py | 77 +++++++++++ 7 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/uptime_kuma/update.py create mode 100644 tests/components/uptime_kuma/snapshots/test_update.ambr create mode 100644 tests/components/uptime_kuma/test_update.py diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py index 0215c83f0cc..68234077976 100644 --- a/homeassistant/components/uptime_kuma/__init__.py +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -2,16 +2,37 @@ from __future__ import annotations +from pythonkuma.update import UpdateChecker + from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.hass_dict import HassKey -from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +UPTIME_KUMA_KEY: HassKey[UptimeKumaSoftwareUpdateCoordinator] = HassKey(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: """Set up Uptime Kuma from a config entry.""" + if UPTIME_KUMA_KEY not in hass.data: + session = async_get_clientsession(hass) + update_checker = UpdateChecker(session) + + update_coordinator = UptimeKumaSoftwareUpdateCoordinator(hass, update_checker) + await update_coordinator.async_request_refresh() + + hass.data[UPTIME_KUMA_KEY] = update_coordinator coordinator = UptimeKumaDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -24,4 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + if not hass.config_entries.async_loaded_entries(DOMAIN): + await hass.data[UPTIME_KUMA_KEY].async_shutdown() + hass.data.pop(UPTIME_KUMA_KEY) + return unload_ok diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 297bd83e7c8..58eed420fd8 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -6,12 +6,14 @@ from datetime import timedelta import logging from pythonkuma import ( + UpdateException, UptimeKuma, UptimeKumaAuthenticationException, UptimeKumaException, UptimeKumaMonitor, UptimeKumaVersion, ) +from pythonkuma.update import LatestRelease, UpdateChecker from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL @@ -25,6 +27,9 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL_UPDATES = timedelta(hours=3) + type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] @@ -45,7 +50,7 @@ class UptimeKumaDataUpdateCoordinator( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=SCAN_INTERVAL, ) session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) self.api = UptimeKuma( @@ -105,3 +110,28 @@ def async_migrate_entities_unique_ids( registry_entry.entity_id, new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", ) + + +class UptimeKumaSoftwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): + """Uptime Kuma coordinator for retrieving update information.""" + + def __init__(self, hass: HomeAssistant, update_checker: UpdateChecker) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=DOMAIN, + update_interval=SCAN_INTERVAL_UPDATES, + ) + self.update_checker = update_checker + + async def _async_update_data(self) -> LatestRelease: + """Fetch data.""" + try: + return await self.update_checker.latest_release() + except UpdateException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 87dcf6e8cf7..62b1ccbdd9a 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -106,6 +106,11 @@ "port": { "name": "Monitored port" } + }, + "update": { + "update": { + "name": "Uptime Kuma version" + } } }, "exceptions": { @@ -114,6 +119,9 @@ }, "request_failed_exception": { "message": "Connection to Uptime Kuma failed" + }, + "update_check_failed": { + "message": "Failed to check for latest Uptime Kuma update" } } } diff --git a/homeassistant/components/uptime_kuma/update.py b/homeassistant/components/uptime_kuma/update.py new file mode 100644 index 00000000000..6fe4e477f0b --- /dev/null +++ b/homeassistant/components/uptime_kuma/update.py @@ -0,0 +1,122 @@ +"""Update platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import UPTIME_KUMA_KEY +from .const import DOMAIN +from .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) + +PARALLEL_UPDATES = 0 + + +class UptimeKumaUpdate(StrEnum): + """Uptime Kuma update.""" + + UPDATE = "update" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up update platform.""" + + coordinator = entry.runtime_data + async_add_entities( + [UptimeKumaUpdateEntity(coordinator, hass.data[UPTIME_KUMA_KEY])] + ) + + +class UptimeKumaUpdateEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], UpdateEntity +): + """Representation of an update entity.""" + + entity_description = UpdateEntityDescription( + key=UptimeKumaUpdate.UPDATE, + translation_key=UptimeKumaUpdate.UPDATE, + ) + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + update_coordinator: UptimeKumaSoftwareUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.update_checker = update_coordinator + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.config_entry.title, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Uptime Kuma", + configuration_url=coordinator.config_entry.data[CONF_URL], + sw_version=coordinator.api.version.version, + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{self.entity_description.key}" + ) + + @property + def installed_version(self) -> str | None: + """Current version.""" + + return self.coordinator.api.version.version + + @property + def title(self) -> str | None: + """Title of the release.""" + + return f"Uptime Kuma {self.update_checker.data.name}" + + @property + def release_url(self) -> str | None: + """URL to the full release notes.""" + + return self.update_checker.data.html_url + + @property + def latest_version(self) -> str | None: + """Latest version.""" + + return self.update_checker.data.tag_name + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + return self.update_checker.data.body + + async def async_added_to_hass(self) -> None: + """When entity is added to hass. + + Register extra update listener for the software update coordinator. + """ + await super().async_added_to_hass() + self.async_on_remove( + self.update_checker.async_add_listener(self._handle_coordinator_update) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.update_checker.last_update_success diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py index 4b7710a48b4..7895f068b31 100644 --- a/tests/components/uptime_kuma/conftest.py +++ b/tests/components/uptime_kuma/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion from pythonkuma.models import MonitorStatus +from pythonkuma.update import LatestRelease from homeassistant.components.uptime_kuma.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL @@ -99,3 +100,22 @@ def mock_pythonkuma() -> Generator[AsyncMock]: ) yield client + + +@pytest.fixture(autouse=True) +def mock_update_checker() -> Generator[AsyncMock]: + """Mock Update checker.""" + + with patch( + "homeassistant.components.uptime_kuma.UpdateChecker", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.latest_release.return_value = LatestRelease( + html_url="https://github.com/louislam/uptime-kuma/releases/tag/2.0.1", + name="2.0.1", + tag_name="2.0.1", + body="**RELEASE_NOTES**", + ) + + yield client diff --git a/tests/components/uptime_kuma/snapshots/test_update.ambr b/tests/components/uptime_kuma/snapshots/test_update.ambr new file mode 100644 index 00000000000..225584a5181 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_update.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_update[update.uptime_example_org_uptime_kuma_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Uptime Kuma version', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': '123456789_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.uptime_example_org_uptime_kuma_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/uptime_kuma/icon.png', + 'friendly_name': 'uptime.example.org Uptime Kuma version', + 'in_progress': False, + 'installed_version': '2.0.0', + 'latest_version': '2.0.1', + 'release_summary': None, + 'release_url': 'https://github.com/louislam/uptime-kuma/releases/tag/2.0.1', + 'skipped_version': None, + 'supported_features': , + 'title': 'Uptime Kuma 2.0.1', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/uptime_kuma/test_update.py b/tests/components/uptime_kuma/test_update.py new file mode 100644 index 00000000000..38d58b979a1 --- /dev/null +++ b/tests/components/uptime_kuma/test_update.py @@ -0,0 +1,77 @@ +"""Test the Uptime Kuma update platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import UpdateException +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def update_only() -> AsyncGenerator[None]: + """Enable only the update platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.UPDATE], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the update platform.""" + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.uptime_example_org_uptime_kuma_version", + } + ) + result = await ws_client.receive_json() + assert result["result"] == "**RELEASE_NOTES**" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_update_checker: AsyncMock, +) -> None: + """Test update entity unavailable on error.""" + + mock_update_checker.latest_release.side_effect = UpdateException + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("update.uptime_example_org_uptime_kuma_version") + assert state is not None + assert state.state == STATE_UNAVAILABLE From 4ea7ad52b176304d11852d66525d6f12bac1823d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:09:13 +0200 Subject: [PATCH 170/235] Bump habiticalib to v0.4.1 (#149523) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 8b03e5efe01..d890ed23676 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.0"] + "requirements": ["habiticalib==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f873d3a9be..16978a32045 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.1 # homeassistant.components.bluetooth habluetooth==4.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6c85395d5b..6a4becdab6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -985,7 +985,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.1 # homeassistant.components.bluetooth habluetooth==4.0.1 From a33760bc1a3e9be00865727285c8a8c107dabeab Mon Sep 17 00:00:00 2001 From: Alex Hermann Date: Sun, 27 Jul 2025 19:18:00 +0200 Subject: [PATCH 171/235] Update slixmpp to 1.10.0 (#149374) --- homeassistant/components/xmpp/manifest.json | 2 +- homeassistant/components/xmpp/notify.py | 5 +++-- requirements_all.txt | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index d77d70ff86c..d128e3e5111 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], "quality_scale": "legacy", - "requirements": ["slixmpp==1.8.5", "emoji==2.8.0"] + "requirements": ["slixmpp==1.10.0", "emoji==2.8.0"] } diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 6ad0c1671a9..59ff3f584b4 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -150,7 +150,8 @@ async def async_send_message( # noqa: C901 self.loop = hass.loop - self.force_starttls = use_tls + self.enable_starttls = use_tls + self.enable_direct_tls = use_tls self.use_ipv6 = False self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail) self.add_event_handler("session_start", self.start) @@ -169,7 +170,7 @@ async def async_send_message( # noqa: C901 self.register_plugin("xep_0128") # Service Discovery self.register_plugin("xep_0363") # HTTP upload - self.connect(force_starttls=self.force_starttls, use_ssl=False) + self.connect() async def start(self, event): """Start the communication and sends the message.""" diff --git a/requirements_all.txt b/requirements_all.txt index 16978a32045..6a9f883dc26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2793,7 +2793,7 @@ skyboxremote==0.0.6 slack_sdk==3.33.4 # homeassistant.components.xmpp -slixmpp==1.8.5 +slixmpp==1.10.0 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 From ea2b3b3ff3bfaa55161013a64f315493deea64ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:22:01 +0200 Subject: [PATCH 172/235] Update ical + gcal-sync (#149413) --- homeassistant/components/google/calendar.py | 8 ++++---- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/calendar.py | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/calendar.py | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 6fef46395e8..d6d740bd0aa 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -230,7 +230,7 @@ async def async_setup_entry( calendar_info = calendars[calendar_id] else: calendar_info = get_calendar_info( - hass, calendar_item.dict(exclude_unset=True) + hass, calendar_item.model_dump(exclude_unset=True) ) new_calendars.append(calendar_info) @@ -467,7 +467,7 @@ class GoogleCalendarEntity( else: start = DateOrDatetime(date=dtstart) end = DateOrDatetime(date=dtend) - event = Event.parse_obj( + event = Event.model_validate( { EVENT_SUMMARY: kwargs[EVENT_SUMMARY], "start": start, @@ -538,7 +538,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> if EVENT_IN in call.data: if EVENT_IN_DAYS in call.data[EVENT_IN]: - now = datetime.now() + now = datetime.now().date() start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) end_in = start_in + timedelta(days=1) @@ -547,7 +547,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> end = DateOrDatetime(date=end_in) elif EVENT_IN_WEEKS in call.data[EVENT_IN]: - now = datetime.now() + now = datetime.now().date() start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) end_in = start_in + timedelta(days=1) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 1acfa3a2ad1..b15372b1555 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"] } diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index c8f906c6d54..3b6d6070f5a 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -221,7 +221,7 @@ def _get_calendar_event(event: Event) -> CalendarEvent: end = start + timedelta(days=1) return CalendarEvent( - summary=event.summary, + summary=event.summary or "", start=start, end=end, description=event.description, diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 3bf00f30624..ffe4d379ce5 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 134cea5293b..48aa3032e73 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index f6918ea9706..7009a8af360 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -98,7 +98,7 @@ def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" return CalendarEvent( - summary=event.summary, + summary=event.summary or "", start=( dt_util.as_local(event.start) if isinstance(event.start, datetime) diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 6ba1dea55ed..b4e2d186add 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6a9f883dc26..dbae7e9b1a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -995,7 +995,7 @@ gardena-bluetooth==1.6.0 gassist-text==0.0.14 # homeassistant.components.google -gcal-sync==7.1.0 +gcal-sync==8.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -1210,7 +1210,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.4 +ical==11.0.0 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a4becdab6c..ab10c633944 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -865,7 +865,7 @@ gardena-bluetooth==1.6.0 gassist-text==0.0.14 # homeassistant.components.google -gcal-sync==7.1.0 +gcal-sync==8.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -1050,7 +1050,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.4 +ical==11.0.0 # homeassistant.components.caldav icalendar==6.1.0 From ff4dc393cf09dbe062da2e86d740bceeef14df1e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 27 Jul 2025 20:00:50 +0200 Subject: [PATCH 173/235] Bump reolink-aio to 0.14.4 (#149521) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index f8b8191a851..39541476429 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.3"] + "requirements": ["reolink-aio==0.14.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbae7e9b1a3..80638d4a596 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.3 +reolink-aio==0.14.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab10c633944..895aca708be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2209,7 +2209,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.3 +reolink-aio==0.14.4 # homeassistant.components.rflink rflink==0.0.67 From c99d81a554ed7aa08379dfcdb8d34726ac32480e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 27 Jul 2025 20:02:24 +0200 Subject: [PATCH 174/235] Set PARALLEL_UPDATES in Tankerkoenig platforms (#149518) --- homeassistant/components/tankerkoenig/binary_sensor.py | 3 +++ homeassistant/components/tankerkoenig/sensor.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index a38266e57e8..d571dfe99d2 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -17,6 +17,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index b1646489d96..82c89f90fe4 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -24,6 +24,9 @@ from .const import ( from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) From 431b2aa1d520da6a7d94b39ea48f2f139170c258 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 27 Jul 2025 20:13:05 +0200 Subject: [PATCH 175/235] Add data description strings to Tankerkoenig (#149519) Co-authored-by: Josef Zweck Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/tankerkoenig/strings.json | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index db620b2b11c..3f821c7c6fa 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -1,4 +1,11 @@ { + "common": { + "data_description_api_key": "The tankerkoenig API key to be used.", + "data_description_location": "Pick the location where to search for gas stations.", + "data_description_name": "The name of the particular region to be added.", + "data_description_radius": "The radius in kilometers to search for gas stations around the selected location.", + "data_description_stations": "Select the stations you want to add to Home Assistant." + }, "config": { "step": { "user": { @@ -6,13 +13,21 @@ "name": "Region name", "api_key": "[%key:common::config_flow::data::api_key%]", "location": "[%key:common::config_flow::data::location%]", - "stations": "Additional fuel stations", "radius": "Search radius" + }, + "data_description": { + "name": "[%key:component::tankerkoenig::common::data_description_name%]", + "api_key": "[%key:component::tankerkoenig::common::data_description_api_key%]", + "location": "[%key:component::tankerkoenig::common::data_description_location%]", + "radius": "[%key:component::tankerkoenig::common::data_description_radius%]" } }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::tankerkoenig::common::data_description_api_key%]" } }, "select_station": { @@ -20,6 +35,9 @@ "description": "Found {stations_count} stations in radius", "data": { "stations": "Stations" + }, + "data_description": { + "stations": "[%key:component::tankerkoenig::common::data_description_stations%]" } } }, @@ -39,6 +57,10 @@ "data": { "stations": "[%key:component::tankerkoenig::config::step::select_station::data::stations%]", "show_on_map": "Show stations on map" + }, + "data_description": { + "stations": "[%key:component::tankerkoenig::common::data_description_stations%]", + "show_on_map": "Whether to show the station sensors on the map or not." } } }, From dbb573038966ed8ec43904afb7f0b653d9be8bdb Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 27 Jul 2025 20:35:01 +0200 Subject: [PATCH 176/235] Increase trophy titles retrieval page size to 500 for PlayStation Network (#149528) --- homeassistant/components/playstation_network/coordinator.py | 2 +- homeassistant/components/playstation_network/helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index a9f49f7f7bb..fa00ac2c8ec 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -116,7 +116,7 @@ class PlaystationNetworkTrophyTitlesCoordinator( async def update_data(self) -> list[TrophyTitle]: """Update trophy titles data.""" self.psn.trophy_titles = await self.hass.async_add_executor_job( - lambda: list(self.psn.user.trophy_titles()) + lambda: list(self.psn.user.trophy_titles(page_size=500)) ) await self.config_entry.runtime_data.user_data.async_request_refresh() return self.psn.trophy_titles diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index f7f6143e94f..358e1c13025 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -67,7 +67,7 @@ class PlaystationNetwork: self.user = self.psn.user(online_id="me") self.client = self.psn.me() self.shareable_profile_link = self.client.get_shareable_profile_link() - self.trophy_titles = list(self.user.trophy_titles()) + self.trophy_titles = list(self.user.trophy_titles(page_size=500)) async def async_setup(self) -> None: """Setup PSN.""" From a060f7486f1ccf1f49da59f8aae3745ebcdd6655 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 27 Jul 2025 20:36:25 +0200 Subject: [PATCH 177/235] Replace duplicated strings and fix "street name" in `waze_travel_time` (#149512) --- .../components/waze_travel_time/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 8f8de694b2d..c57f5470b04 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -27,8 +27,8 @@ "data": { "units": "Units", "vehicle_type": "Vehicle type", - "incl_filter": "Exact streetname which must be part of the selected route", - "excl_filter": "Exact streetname which must NOT be part of the selected route", + "incl_filter": "Exact street name which must be part of the selected route", + "excl_filter": "Exact street name which must NOT be part of the selected route", "realtime": "Realtime travel time?", "avoid_toll_roads": "Avoid toll roads?", "avoid_ferries": "Avoid ferries?", @@ -103,12 +103,12 @@ "description": "Whether to avoid subscription roads." }, "incl_filter": { - "name": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]", - "description": "Exact streetname which must be part of the selected route." + "name": "Streets to include", + "description": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]" }, "excl_filter": { - "name": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]", - "description": "Exact streetname which must NOT be part of the selected route." + "name": "Streets to exclude", + "description": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]" } } } From 1fa9141ce102796cc7abdd54f886431f2289c50b Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 27 Jul 2025 21:52:53 +0200 Subject: [PATCH 178/235] Bump uiprotect to version 7.20.0 (#149533) --- 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 2f79154e0c5..8eee080abb4 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.19.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.20.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 80638d4a596..6f89bdf8d8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.19.0 +uiprotect==7.20.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 895aca708be..68b6b38c77e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.19.0 +uiprotect==7.20.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 622cce03a1418c01c1e78b646b09d41282ecac11 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:46:59 +0200 Subject: [PATCH 179/235] Bump aioautomower to 2.1.0 (#149541) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 798bd631e43..f5de5a3dff8 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.0.2"] + "requirements": ["aioautomower==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f89bdf8d8a..7c1e7058f0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.2 +aioautomower==2.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68b6b38c77e..b1a8dee354f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.2 +aioautomower==2.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From e30d40562527e5d3c3fe88bf1421518e334d9446 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:48:15 +0200 Subject: [PATCH 180/235] Enable strict typing in Tankerkoenig (#149535) --- .strict-typing | 1 + homeassistant/components/tankerkoenig/config_flow.py | 4 ++-- homeassistant/components/tankerkoenig/sensor.py | 11 +++++++++-- mypy.ini | 10 ++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 18e72162a23..3f87bfa18e8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -501,6 +501,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tailwind.* homeassistant.components.tami4.* +homeassistant.components.tankerkoenig.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.technove.* diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 9aeb0a80173..6207c7261b0 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -15,7 +15,6 @@ from aiotankerkoenig import ( import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -40,6 +39,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN +from .coordinator import TankerkoenigConfigEntry async def async_get_nearby_stations( @@ -71,7 +71,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: TankerkoenigConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 82c89f90fe4..9964a300d6f 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -110,7 +110,14 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): self._attr_extra_state_attributes = attrs @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the current price for the fuel type.""" info = self.coordinator.data[self._station_id] - return getattr(info, self._fuel_type) + result = None + if self._fuel_type is GasType.E10: + result = info.e10 + elif self._fuel_type is GasType.E5: + result = info.e5 + else: + result = info.diesel + return result diff --git a/mypy.ini b/mypy.ini index bff6c93967e..bfd9cfb0a84 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4768,6 +4768,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tankerkoenig.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tautulli.*] check_untyped_defs = true disallow_incomplete_defs = true From f35558413acb512c572493cf6b6bce28f073dd59 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 28 Jul 2025 15:58:59 +1000 Subject: [PATCH 181/235] Bump tesla-fleet-api to 1.2.3 (#149550) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index cf86fbeb4f9..3420ed9f46e 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.2"] + "requirements": ["tesla-fleet-api==1.2.3"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index d12cf278d59..b6aff150a96 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.2.2", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.3", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 26f26990d58..e2ebf64f241 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.2"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c1e7058f0c..dbdc82ac06a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2911,7 +2911,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.2 +tesla-fleet-api==1.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1a8dee354f..976318e7733 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2397,7 +2397,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.2 +tesla-fleet-api==1.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From ab6cd0eb41c40f5f1b7321b683a45c4cffefc24a Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Mon, 28 Jul 2025 09:42:40 +0300 Subject: [PATCH 182/235] Bump israel-rail to 0.1.3 (#149555) --- homeassistant/components/israel_rail/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json index afe085f5729..33e4219bbac 100644 --- a/homeassistant/components/israel_rail/manifest.json +++ b/homeassistant/components/israel_rail/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/israel_rail", "iot_class": "cloud_polling", "loggers": ["israelrailapi"], - "requirements": ["israel-rail-api==0.1.2"] + "requirements": ["israel-rail-api==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbdc82ac06a..38770807246 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1273,7 +1273,7 @@ isal==1.7.1 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.2 +israel-rail-api==0.1.3 # homeassistant.components.abode jaraco.abode==6.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 976318e7733..d5ee2f35922 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1104,7 +1104,7 @@ isal==1.7.1 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.2 +israel-rail-api==0.1.3 # homeassistant.components.abode jaraco.abode==6.2.1 From c67636b4f6114efce6c6745b7d97cac0d39aeb33 Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Mon, 28 Jul 2025 11:35:52 +0300 Subject: [PATCH 183/235] Add support for EVs in `ituran` (#149484) --- .../components/ituran/device_tracker.py | 6 +- homeassistant/components/ituran/icons.json | 3 + homeassistant/components/ituran/sensor.py | 28 +- homeassistant/components/ituran/strings.json | 3 + tests/components/ituran/conftest.py | 16 +- .../ituran/snapshots/test_sensor.ambr | 411 ++++++++++++++++++ tests/components/ituran/test_sensor.py | 48 +- 7 files changed, 503 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py index 5f816709864..0656bdfa497 100644 --- a/homeassistant/components/ituran/device_tracker.py +++ b/homeassistant/components/ituran/device_tracker.py @@ -2,6 +2,8 @@ from __future__ import annotations +from propcache.api import cached_property + from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,12 +40,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity): """Initialize the device tracker.""" super().__init__(coordinator, license_plate, "device_tracker") - @property + @cached_property def latitude(self) -> float | None: """Return latitude value of the device.""" return self.vehicle.gps_coordinates[0] - @property + @cached_property def longitude(self) -> float | None: """Return longitude value of the device.""" return self.vehicle.gps_coordinates[1] diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json index bd9182f1569..0b721ca5001 100644 --- a/homeassistant/components/ituran/icons.json +++ b/homeassistant/components/ituran/icons.json @@ -9,6 +9,9 @@ "address": { "default": "mdi:map-marker" }, + "battery_range": { + "default": "mdi:ev-station" + }, "battery_voltage": { "default": "mdi:car-battery" }, diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py index a115b2be89c..50e86b374a1 100644 --- a/homeassistant/components/ituran/sensor.py +++ b/homeassistant/components/ituran/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from propcache.api import cached_property from pyituran import Vehicle from homeassistant.components.sensor import ( @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( DEGREE, + PERCENTAGE, UnitOfElectricPotential, UnitOfLength, UnitOfSpeed, @@ -33,6 +35,7 @@ class IturanSensorEntityDescription(SensorEntityDescription): """Describes Ituran sensor entity.""" value_fn: Callable[[Vehicle], StateType | datetime] + supported_fn: Callable[[Vehicle], bool] = lambda _: True SENSOR_TYPES: list[IturanSensorEntityDescription] = [ @@ -42,6 +45,22 @@ SENSOR_TYPES: list[IturanSensorEntityDescription] = [ entity_registry_enabled_default=False, value_fn=lambda vehicle: vehicle.address, ), + IturanSensorEntityDescription( + key="battery_level", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda vehicle: vehicle.battery_level, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), + IturanSensorEntityDescription( + key="battery_range", + translation_key="battery_range", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=0, + value_fn=lambda vehicle: vehicle.battery_range, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), IturanSensorEntityDescription( key="battery_voltage", translation_key="battery_voltage", @@ -92,14 +111,15 @@ async def async_setup_entry( """Set up the Ituran sensors from config entry.""" coordinator = config_entry.runtime_data async_add_entities( - IturanSensor(coordinator, license_plate, description) + IturanSensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() for description in SENSOR_TYPES - for license_plate in coordinator.data + if description.supported_fn(vehicle) ) class IturanSensor(IturanBaseEntity, SensorEntity): - """Ituran device tracker.""" + """Ituran sensor.""" entity_description: IturanSensorEntityDescription @@ -113,7 +133,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity): super().__init__(coordinator, license_plate, description.key) self.entity_description = description - @property + @cached_property def native_value(self) -> StateType | datetime: """Return the state of the device.""" return self.entity_description.value_fn(self.vehicle) diff --git a/homeassistant/components/ituran/strings.json b/homeassistant/components/ituran/strings.json index efc60ef454b..ededb5232f5 100644 --- a/homeassistant/components/ituran/strings.json +++ b/homeassistant/components/ituran/strings.json @@ -40,6 +40,9 @@ "address": { "name": "Address" }, + "battery_range": { + "name": "Remaining range" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index 5093cc301a1..1cb922b94e9 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -47,7 +47,7 @@ def mock_config_entry() -> MockConfigEntry: class MockVehicle: """Mock vehicle.""" - def __init__(self) -> None: + def __init__(self, is_electric_vehicle=False) -> None: """Initialize mock vehicle.""" self.license_plate = "12345678" self.make = "mock make" @@ -61,11 +61,18 @@ class MockVehicle: 2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("Asia/Jerusalem") ) self.battery_voltage = 12.0 + self.is_electric_vehicle = is_electric_vehicle + if is_electric_vehicle: + self.battery_level = 42 + self.battery_range = 150 + else: + self.battery_level = 0 + self.battery_range = 0 @pytest.fixture -def mock_ituran() -> Generator[AsyncMock]: - """Return a mocked PalazzettiClient.""" +def mock_ituran(request: pytest.FixtureRequest) -> Generator[AsyncMock]: + """Return a mocked Ituran.""" with ( patch( "homeassistant.components.ituran.coordinator.Ituran", @@ -79,7 +86,8 @@ def mock_ituran() -> Generator[AsyncMock]: mock_ituran = ituran.return_value mock_ituran.is_authenticated.return_value = False mock_ituran.authenticate.return_value = True - mock_ituran.get_vehicles.return_value = [MockVehicle()] + is_electric_vehicle = getattr(request, "param", False) + mock_ituran.get_vehicles.return_value = [MockVehicle(is_electric_vehicle)] type(mock_ituran).mobile_id = PropertyMock( return_value=MOCK_CONFIG_DATA[CONF_MOBILE_ID] ) diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr index 5278c657a66..a577d836b0e 100644 --- a/tests/components/ituran/snapshots/test_sensor.ambr +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -1,4 +1,415 @@ # serializer version: 1 +# name: test_ev_sensor[True][sensor.mock_model_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Address', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'address', + 'unique_id': '12345678-address', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Address', + }), + 'context': , + 'entity_id': 'sensor.mock_model_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bermuda Triangle', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'mock model Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '12345678-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'mock model Battery voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_heading-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_heading', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heading', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heading', + 'unique_id': '12345678-heading', + 'unit_of_measurement': '°', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_heading-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Heading', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.mock_model_heading', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_last_update_from_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update from vehicle', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_update_from_vehicle', + 'unique_id': '12345678-last_update_from_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_last_update_from_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'mock model Last update from vehicle', + }), + 'context': , + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-12-31T22:00:00+00:00', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': '12345678-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Mileage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_remaining_range-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_remaining_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_range', + 'unique_id': '12345678-battery_range', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_remaining_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Remaining range', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_remaining_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-speed', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'mock model Speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- # name: test_sensor[sensor.mock_model_address-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ituran/test_sensor.py b/tests/components/ituran/test_sensor.py index a057f59b81f..4293cf08f2d 100644 --- a/tests/components/ituran/test_sensor.py +++ b/tests/components/ituran/test_sensor.py @@ -32,13 +32,27 @@ async def test_sensor( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_availability( +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def __test_availability( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_ituran: AsyncMock, mock_config_entry: MockConfigEntry, + ev_entity_names: list[str] | None = None, ) -> None: - """Test sensor is marked as unavailable when we can't reach the Ituran service.""" entities = [ "sensor.mock_model_address", "sensor.mock_model_battery_voltage", @@ -46,6 +60,7 @@ async def test_availability( "sensor.mock_model_last_update_from_vehicle", "sensor.mock_model_mileage", "sensor.mock_model_speed", + *(ev_entity_names if ev_entity_names is not None else []), ] await setup_integration(hass, mock_config_entry) @@ -74,3 +89,32 @@ async def test_availability( state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ICE sensor is marked as unavailable when we can't reach the Ituran service.""" + await __test_availability(hass, freezer, mock_ituran, mock_config_entry) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test EV sensor is marked as unavailable when we can't reach the Ituran service.""" + ev_entities = [ + "sensor.mock_model_battery", + "sensor.mock_model_remaining_range", + ] + await __test_availability( + hass, freezer, mock_ituran, mock_config_entry, ev_entities + ) From 05935bbc01d3f5db0d5544b72f3bdd02ef075138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 28 Jul 2025 10:17:26 +0100 Subject: [PATCH 184/235] Bump hass-nabucasa from 0.108.0 to 0.110.0 (#149560) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 72748efff6e..a819203e549 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.108.0"], + "requirements": ["hass-nabucasa==0.110.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 88aa9418ddc..a43eadce0de 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.3 diff --git a/pyproject.toml b/pyproject.toml index 162f63ff064..b75b80f47dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.108.0", + "hass-nabucasa==0.110.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 65d0309747e..6110854f5f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 38770807246..b2e14e4241c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5ee2f35922..21eab297f03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -991,7 +991,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From a68e722c929498643df46c2275450db45ec6e8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 28 Jul 2025 11:33:03 +0200 Subject: [PATCH 185/235] Matter MicrowaveOven device (#148219) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/icons.json | 8 ++ homeassistant/components/matter/number.py | 62 ++++++++++++---- homeassistant/components/matter/select.py | 28 ++++++- homeassistant/components/matter/strings.json | 6 ++ .../matter/fixtures/nodes/microwave_oven.json | 2 + .../matter/snapshots/test_number.ambr | 59 +++++++++++++++ .../matter/snapshots/test_select.ambr | 73 +++++++++++++++++++ tests/components/matter/test_number.py | 33 +++++++++ tests/components/matter/test_select.py | 47 ++++++++++++ 9 files changed, 300 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 32f822414aa..2b9ca2cc3e2 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -40,6 +40,9 @@ "laundry_washer_spin_speed": { "default": "mdi:reload" }, + "power_level": { + "default": "mdi:power-settings" + }, "temperature_level": { "default": "mdi:thermometer" } @@ -115,6 +118,11 @@ "default": "mdi:pump" } }, + "number": { + "cook_time": { + "default": "mdi:microwave" + } + }, "switch": { "child_lock": { "default": "mdi:lock", diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index ea348c20012..4456496d52e 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any from chip.clusters import Objects as clusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand @@ -55,12 +55,16 @@ class MatterRangeNumberEntityDescription( ): """Describe Matter Number Input entities with min and max values.""" - ha_to_device: Callable[[Any], Any] + ha_to_device: Callable[[Any], Any] = lambda x: x # attribute descriptors to get the min and max value - min_attribute: type[ClusterAttributeDescriptor] + min_attribute: type[ClusterAttributeDescriptor] | None = None max_attribute: type[ClusterAttributeDescriptor] + # Functions to format the min and max values for display or conversion + format_min_value: Callable[[float], float] = lambda x: x + format_max_value: Callable[[float], float] = lambda x: x + # command: a custom callback to create the command to send to the device # the callback's argument will be the index of the selected list value command: Callable[[int], ClusterCommand] @@ -105,24 +109,29 @@ class MatterRangeNumber(MatterEntity, NumberEntity): @callback def _update_from_device(self) -> None: """Update from device.""" + # get the value from the primary attribute and convert it to the HA value if needed value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value - self._attr_native_min_value = ( - cast( - int, - self.get_matter_attribute_value(self.entity_description.min_attribute), + + # min case 1: get min from the attribute and convert it + if self.entity_description.min_attribute: + min_value = self.get_matter_attribute_value( + self.entity_description.min_attribute ) - / 100 - ) - self._attr_native_max_value = ( - cast( - int, - self.get_matter_attribute_value(self.entity_description.max_attribute), - ) - / 100 + min_convert = self.entity_description.format_min_value + self._attr_native_min_value = min_convert(min_value) + # min case 2: get the min from entity_description + elif self.entity_description.native_min_value is not None: + self._attr_native_min_value = self.entity_description.native_min_value + + # get max from the attribute and convert it + max_value = self.get_matter_attribute_value( + self.entity_description.max_attribute ) + max_convert = self.entity_description.format_max_value + self._attr_native_max_value = max_convert(max_value) class MatterLevelControlNumber(MatterEntity, NumberEntity): @@ -302,6 +311,27 @@ DISCOVERY_SCHEMAS = [ clusters.OccupancySensing.Attributes.PIROccupiedToUnoccupiedDelay, ), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="MicrowaveOvenControlCookTime", + translation_key="cook_time", + device_class=NumberDeviceClass.DURATION, + command=lambda value: clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + cookTime=int(value) + ), + native_min_value=1, # 1 second minimum cook time + native_step=1, # 1 second + native_unit_of_measurement=UnitOfTime.SECONDS, + max_attribute=clusters.MicrowaveOvenControl.Attributes.MaxCookTime, + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.MicrowaveOvenControl.Attributes.CookTime, + clusters.MicrowaveOvenControl.Attributes.MaxCookTime, + ), + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( @@ -328,6 +358,8 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_to_ha=lambda x: None if x is None else x / 100, ha_to_device=lambda x: round(x * 100), + format_min_value=lambda x: x / 100, + format_max_value=lambda x: x / 100, min_attribute=clusters.TemperatureControl.Attributes.MinTemperature, max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature, mode=NumberMode.SLIDER, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index d700b39258c..5d7a5363da0 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -197,10 +197,14 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - list_values = cast( - list[str], - self.get_matter_attribute_value(self.entity_description.list_attribute), + list_values_raw = self.get_matter_attribute_value( + self.entity_description.list_attribute ) + if TYPE_CHECKING: + assert list_values_raw is not None + + # Accept both list[str] and list[int], convert to str + list_values = [str(v) for v in list_values_raw] self._attr_options = list_values current_option_idx: int = self.get_matter_attribute_value( self._entity_info.primary_attribute @@ -443,6 +447,24 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported rinses list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="MicrowaveOvenControlSelectedWattIndex", + translation_key="power_level", + command=lambda selected_index: clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + wattSettingIndex=selected_index + ), + list_attribute=clusters.MicrowaveOvenControl.Attributes.SupportedWatts, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.MicrowaveOvenControl.Attributes.SelectedWattIndex, + clusters.MicrowaveOvenControl.Attributes.SupportedWatts, + ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 7f603c9d188..749cf387a40 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -180,6 +180,9 @@ "altitude": { "name": "Altitude above sea level" }, + "cook_time": { + "name": "Cook time" + }, "pump_setpoint": { "name": "Setpoint" }, @@ -222,6 +225,9 @@ "device_energy_management_mode": { "name": "Energy management mode" }, + "power_level": { + "name": "Power level (W)" + }, "sensitivity_level": { "name": "Sensitivity", "state": { diff --git a/tests/components/matter/fixtures/nodes/microwave_oven.json b/tests/components/matter/fixtures/nodes/microwave_oven.json index bbba8b12e25..0e693b8337f 100644 --- a/tests/components/matter/fixtures/nodes/microwave_oven.json +++ b/tests/components/matter/fixtures/nodes/microwave_oven.json @@ -397,6 +397,8 @@ "1/96/5": { "0": 0 }, + "1/96/6": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + "1/96/7": 5, "1/96/65532": 2, "1/96/65533": 2, "1/96/65528": [4], diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index da709615610..f7f467b4ed0 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -693,6 +693,65 @@ 'state': '255', }) # --- +# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 86400, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.microwave_oven_cook_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cook time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-MicrowaveOvenControlCookTime-95-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Microwave Oven Cook time', + 'max': 86400, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.microwave_oven_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- # name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 092928ff1d4..add827abc5a 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -981,6 +981,79 @@ 'state': 'Low', }) # --- +# name: test_selects[microwave_oven][select.microwave_oven_power_level_w-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '1000', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.microwave_oven_power_level_w', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power level (W)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_level', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-MicrowaveOvenControlSelectedWattIndex-95-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[microwave_oven][select.microwave_oven_power_level_w-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Power level (W)', + 'options': list([ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '1000', + ]), + }), + 'context': , + 'entity_id': 'select.microwave_oven_power_level_w', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- # name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 0ba2886b089..b59e6848f63 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -201,3 +201,36 @@ async def test_pump_level( ), # 75 * 2 = 150, as the value is multiplied by 2 in the HA to native value conversion ) ) + + +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_microwave_oven( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Cooktime for microwave oven.""" + + # Cooktime on MicrowaveOvenControl cluster (1/96/2) + state = hass.states.get("number.microwave_oven_cook_time") + assert state + assert state.state == "30" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.microwave_oven_cook_time", + "value": 60, # 60 seconds + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + cookTime=60, # 60 seconds + ), + ) diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 7045b60a24e..c264f51b669 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -235,3 +235,50 @@ async def test_pump( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.mock_pump_mode") assert state.state == "local" + + +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_microwave_oven( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test ListSelect entity is discovered and working from a microwave oven fixture.""" + + # SupportedWatts from MicrowaveOvenControl cluster (1/96/6) + # SelectedWattIndex from MicrowaveOvenControl cluster (1/96/7) + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.microwave_oven_power_level_w") + assert state + assert state.state == "1000" + assert state.attributes["options"] == [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + ] + + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.microwave_oven_power_level_w", + "option": "900", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + wattSettingIndex=8 + ), + ) From ebad1ff4cc70641507bd0ce6da1bff2b09223eeb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 28 Jul 2025 11:59:11 +0200 Subject: [PATCH 186/235] Fix capitalization of "IP address" in `goalzero` (#149563) --- homeassistant/components/goalzero/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index c6d85bd4c10..8c0477c8f6a 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -12,7 +12,7 @@ } }, "confirm_discovery": { - "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual." + "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new IP address. Refer to your router's user manual." } }, "error": { From 18c5437fe708be2e36556a9c306ed055d66225d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 28 Jul 2025 11:42:40 +0100 Subject: [PATCH 187/235] Revert "Make default title configurable in XMPP" (#149544) --- homeassistant/components/xmpp/notify.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 59ff3f584b4..c9829746d59 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -48,7 +48,6 @@ ATTR_URL = "url" ATTR_URL_TEMPLATE = "url_template" ATTR_VERIFY = "verify" -CONF_TITLE = "title" CONF_TLS = "tls" CONF_VERIFY = "verify" @@ -65,7 +64,6 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( vol.Optional(CONF_ROOM, default=""): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, - vol.Optional(CONF_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, } ) @@ -84,7 +82,6 @@ async def async_get_service( config.get(CONF_TLS), config.get(CONF_VERIFY), config.get(CONF_ROOM), - config.get(CONF_TITLE), hass, ) @@ -92,9 +89,7 @@ async def async_get_service( class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__( - self, sender, resource, password, recipient, tls, verify, room, title, hass - ): + def __init__(self, sender, resource, password, recipient, tls, verify, room, hass): """Initialize the service.""" self._hass = hass self._sender = sender @@ -104,11 +99,10 @@ class XmppNotificationService(BaseNotificationService): self._tls = tls self._verify = verify self._room = room - self._title = title async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, self._title) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) text = f"{title}: {message}" if title else message data = kwargs.get(ATTR_DATA) timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT) if data else None From 40ce228c9c599129ecfd12403064570a399900d9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:12:16 +0200 Subject: [PATCH 188/235] Add upload_file action to immich integration (#147295) Co-authored-by: Norbert Rittel --- homeassistant/components/immich/__init__.py | 12 + homeassistant/components/immich/icons.json | 5 + homeassistant/components/immich/services.py | 98 +++++++ homeassistant/components/immich/services.yaml | 18 ++ homeassistant/components/immich/strings.json | 37 +++ tests/components/immich/conftest.py | 29 +- tests/components/immich/test_services.py | 277 ++++++++++++++++++ 7 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/immich/services.py create mode 100644 homeassistant/components/immich/services.yaml create mode 100644 tests/components/immich/test_services.py diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py index d40615dbe88..996e4f3ad8c 100644 --- a/homeassistant/components/immich/__init__.py +++ b/homeassistant/components/immich/__init__.py @@ -16,13 +16,25 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up immich integration.""" + await async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: """Set up Immich from a config entry.""" diff --git a/homeassistant/components/immich/icons.json b/homeassistant/components/immich/icons.json index 15bac6370a6..aefce3ed615 100644 --- a/homeassistant/components/immich/icons.json +++ b/homeassistant/components/immich/icons.json @@ -11,5 +11,10 @@ "default": "mdi:file-video" } } + }, + "services": { + "upload_file": { + "service": "mdi:upload" + } } } diff --git a/homeassistant/components/immich/services.py b/homeassistant/components/immich/services.py new file mode 100644 index 00000000000..fffd5d9110b --- /dev/null +++ b/homeassistant/components/immich/services.py @@ -0,0 +1,98 @@ +"""Services for the Immich integration.""" + +import logging + +from aioimmich.exceptions import ImmichError +import voluptuous as vol + +from homeassistant.components.media_source import async_resolve_media +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import MediaSelector + +from .const import DOMAIN +from .coordinator import ImmichConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONF_ALBUM_ID = "album_id" +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_FILE = "file" + +SERVICE_UPLOAD_FILE = "upload_file" +SERVICE_SCHEMA_UPLOAD_FILE = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): str, + vol.Required(CONF_FILE): MediaSelector({"accept": ["image/*", "video/*"]}), + vol.Optional(CONF_ALBUM_ID): str, + } +) + + +async def _async_upload_file(service_call: ServiceCall) -> None: + """Call immich upload file service.""" + _LOGGER.debug( + "Executing service %s with arguments %s", + service_call.service, + service_call.data, + ) + hass = service_call.hass + target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry( + service_call.data[CONF_CONFIG_ENTRY_ID] + ) + source_media_id = service_call.data[CONF_FILE]["media_content_id"] + + if not target_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + ) + + if target_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + ) + + media = await async_resolve_media(hass, source_media_id, None) + if media.path is None: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="only_local_media_supported" + ) + + coordinator = target_entry.runtime_data + + if target_album := service_call.data.get(CONF_ALBUM_ID): + try: + await coordinator.api.albums.async_get_album_info(target_album, True) + except ImmichError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="album_not_found", + translation_placeholders={"album_id": target_album, "error": str(ex)}, + ) from ex + + try: + upload_result = await coordinator.api.assets.async_upload_asset(str(media.path)) + if target_album: + await coordinator.api.albums.async_add_assets_to_album( + target_album, [upload_result.asset_id] + ) + except ImmichError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="upload_failed", + translation_placeholders={"file": str(media.path), "error": str(ex)}, + ) from ex + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for immich integration.""" + + hass.services.async_register( + DOMAIN, + SERVICE_UPLOAD_FILE, + _async_upload_file, + SERVICE_SCHEMA_UPLOAD_FILE, + ) diff --git a/homeassistant/components/immich/services.yaml b/homeassistant/components/immich/services.yaml new file mode 100644 index 00000000000..7924a6a112c --- /dev/null +++ b/homeassistant/components/immich/services.yaml @@ -0,0 +1,18 @@ +upload_file: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: immich + file: + required: true + selector: + media: + accept: + - image/* + - video/* + album_id: + required: false + selector: + text: diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json index 83ee7574630..90fccfa1bb1 100644 --- a/homeassistant/components/immich/strings.json +++ b/homeassistant/components/immich/strings.json @@ -74,5 +74,42 @@ "name": "Version" } } + }, + "services": { + "upload_file": { + "name": "Upload file", + "description": "Uploads a file to your Immich instance.", + "fields": { + "config_entry_id": { + "name": "Immich instance", + "description": "The Immich instance where to upload the file." + }, + "file": { + "name": "File", + "description": "The path to the file to be uploaded." + }, + "album_id": { + "name": "Album ID", + "description": "The album in which the file should be placed after uploading." + } + } + } + }, + "exceptions": { + "config_entry_not_found": { + "message": "Config entry not found." + }, + "config_entry_not_loaded": { + "message": "Config entry not loaded." + }, + "only_local_media_supported": { + "message": "Only local media files are currently supported." + }, + "album_not_found": { + "message": "Album with ID `{album_id}` not found ({error})." + }, + "upload_failed": { + "message": "Upload of file `{file}` failed ({error})." + } } } diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 6c7813cbd85..48e36e70386 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -1,9 +1,12 @@ """Common fixtures for the Immich tests.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import AsyncMock, patch +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers +from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse +from aioimmich.assets.models import ImmichAssetUploadResponse from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, @@ -14,6 +17,7 @@ from aioimmich.users.models import ImmichUserObject import pytest from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.media_source import PlayMedia from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -62,6 +66,12 @@ def mock_immich_albums() -> AsyncMock: mock = AsyncMock(spec=ImmichAlbums) mock.async_get_all_albums.return_value = [MOCK_ALBUM_WITHOUT_ASSETS] mock.async_get_album_info.return_value = MOCK_ALBUM_WITH_ASSETS + mock.async_add_assets_to_album.return_value = [ + ImmichAddAssetsToAlbumResponse.from_dict( + {"id": "abcdef-0123456789", "success": True} + ) + ] + return mock @@ -71,6 +81,9 @@ def mock_immich_assets() -> AsyncMock: mock = AsyncMock(spec=ImmichAssests) mock.async_view_asset.return_value = b"xxxx" mock.async_play_video_stream.return_value = MockStreamReaderChunked(b"xxxx") + mock.async_upload_asset.return_value = ImmichAssetUploadResponse.from_dict( + {"id": "abcdef-0123456789", "status": "created"} + ) return mock @@ -195,6 +208,20 @@ async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock: return mock_immich +@pytest.fixture +def mock_media_source() -> Generator[MagicMock]: + """Mock the media source.""" + with patch( + "homeassistant.components.immich.services.async_resolve_media", + return_value=PlayMedia( + url="media-source://media_source/local/screenshot.jpg", + mime_type="image/jpeg", + path=Path("/media/screenshot.jpg"), + ), + ) as mock_media: + yield mock_media + + @pytest.fixture async def setup_media_source(hass: HomeAssistant) -> None: """Set up media source.""" diff --git a/tests/components/immich/test_services.py b/tests/components/immich/test_services.py new file mode 100644 index 00000000000..5ba7cf96408 --- /dev/null +++ b/tests/components/immich/test_services.py @@ -0,0 +1,277 @@ +"""Test the Immich services.""" + +from unittest.mock import Mock, patch + +from aioimmich.exceptions import ImmichError, ImmichNotFoundError +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.immich.services import SERVICE_UPLOAD_FILE +from homeassistant.components.media_source import PlayMedia +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_services( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup of immich services.""" + await setup_integration(hass, mock_config_entry) + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_UPLOAD_FILE in services + + +async def test_upload_file( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg") + mock_immich.albums.async_get_album_info.assert_not_called() + mock_immich.albums.async_add_assets_to_album.assert_not_called() + + +async def test_upload_file_to_album( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service with target album_id.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) + + mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg") + mock_immich.albums.async_get_album_info.assert_called_with( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", True + ) + mock_immich.albums.async_add_assets_to_album.assert_called_with( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", ["abcdef-0123456789"] + ) + + +async def test_upload_file_config_entry_not_found( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload_file service raising config_entry_not_found.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError, match="Config entry not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": "unknown_entry", + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_config_entry_not_loaded( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload_file service raising config_entry_not_loaded.""" + mock_config_entry.disabled_by = er.RegistryEntryDisabler.USER + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError, match="Config entry not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_only_local_media_supported( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising only_local_media_supported.""" + await setup_integration(hass, mock_config_entry) + with ( + patch( + "homeassistant.components.immich.services.async_resolve_media", + return_value=PlayMedia( + url="media-source://media_source/camera/some_entity_id", + mime_type="image/jpeg", + path=None, # Simulate non-local media + ), + ), + pytest.raises( + ServiceValidationError, + match="Only local media files are currently supported", + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_album_not_found( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising album_not_found.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.albums.async_get_album_info.side_effect = ImmichNotFoundError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + + with pytest.raises( + ServiceValidationError, + match="Album with ID `721e1a4b-aa12-441e-8d3b-5ac7ab283bb6` not found", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) + + +async def test_upload_file_upload_failed( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising upload_failed.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.assets.async_upload_asset.side_effect = ImmichError( + { + "message": "Boom! Upload failed", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + with pytest.raises( + ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_to_album_upload_failed( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service with target album_id raising upload_failed.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.albums.async_add_assets_to_album.side_effect = ImmichError( + { + "message": "Boom! Add to album failed.", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + with pytest.raises( + ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) From 140f56aeaa4d7fe4be143a8c5f67c56c9afa90b2 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 07:12:52 -0400 Subject: [PATCH 189/235] Add common translation strings (#149472) Co-authored-by: Martin Hjelmare --- .../components/template/strings.json | 157 ++++++++++-------- 1 file changed, 84 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index a8c2e7660dc..e178b383a78 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,10 +1,21 @@ { + "common": { + "advanced_options": "Advanced options", + "availability": "Availability template", + "code_format": "Code format", + "device_class": "Device class", + "device_id_description": "Select a device to link to this entity.", + "state": "State", + "turn_off": "Actions on turn off", + "turn_on": "Actions on turn on", + "unit_of_measurement": "Unit of measurement" + }, "config": { "step": { "alarm_control_panel": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "value_template": "[%key:component::template::common::state%]", "name": "[%key:common::config_flow::data::name%]", "disarm": "Disarm action", "arm_away": "Arm away action", @@ -14,16 +25,16 @@ "arm_vacation": "Arm vacation action", "trigger": "Trigger action", "code_arm_required": "Code arm required", - "code_format": "Code format" + "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -32,18 +43,18 @@ "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]" + "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -52,18 +63,18 @@ "button": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", "press": "Actions on press" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -77,13 +88,13 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -93,21 +104,21 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "step": "Step value", "set_value": "Actions on set value", "max": "Maximum value", "min": "Minimum value", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -117,18 +128,18 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "select_option": "Actions on select", "options": "Available options" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -137,20 +148,20 @@ "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "Device class", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", - "state": "State template", - "unit_of_measurement": "Unit of measurement" + "state": "[%key:component::template::common::state%]", + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "Select a device to link to this entity." + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "Advanced options", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "Availability template" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -174,19 +185,19 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "turn_off": "Actions on turn off", - "turn_on": "Actions on turn on", - "value_template": "Value template" + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "value_template": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", + "device_id": "[%key:component::template::common::device_id_description%]", "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -199,7 +210,7 @@ "alarm_control_panel": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "value_template": "[%key:component::template::common::state%]", "disarm": "[%key:component::template::config::step::alarm_control_panel::data::disarm%]", "arm_away": "[%key:component::template::config::step::alarm_control_panel::data::arm_away%]", "arm_custom_bypass": "[%key:component::template::config::step::alarm_control_panel::data::arm_custom_bypass%]", @@ -208,16 +219,16 @@ "arm_vacation": "[%key:component::template::config::step::alarm_control_panel::data::arm_vacation%]", "trigger": "[%key:component::template::config::step::alarm_control_panel::data::trigger%]", "code_arm_required": "[%key:component::template::config::step::alarm_control_panel::data::code_arm_required%]", - "code_format": "[%key:component::template::config::step::alarm_control_panel::data::code_format%]" + "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -226,16 +237,16 @@ "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "state": "[%key:component::template::config::step::sensor::data::state%]" + "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -247,13 +258,13 @@ "press": "[%key:component::template::config::step::button::data::press%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -266,13 +277,13 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -282,20 +293,20 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "step": "[%key:component::template::config::step::number::data::step%]", "set_value": "[%key:component::template::config::step::number::data::set_value%]", "max": "[%key:component::template::config::step::number::data::max%]", "min": "[%key:component::template::config::step::number::data::min%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -305,18 +316,18 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "select_option": "[%key:component::template::config::step::select::data::select_option%]", "options": "[%key:component::template::config::step::select::data::options%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -325,19 +336,19 @@ "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" + "state": "[%key:component::template::common::state%]", + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -347,19 +358,19 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", - "turn_off": "[%key:component::template::config::step::switch::data::turn_off%]", - "turn_on": "[%key:component::template::config::step::switch::data::turn_on%]" + "value_template": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", + "device_id": "[%key:component::template::common::device_id_description%]", "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, From 95c5a91f01f7ff8fc923a0ad20f6a1049e24eb45 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:13:08 +0200 Subject: [PATCH 190/235] Refactor active session handling in PlaystationNetwork (#149559) --- .../components/playstation_network/helpers.py | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 358e1c13025..9960d8afd79 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -107,30 +107,34 @@ class PlaystationNetwork: data.shareable_profile_link = self.shareable_profile_link data.availability = data.presence["basicPresence"]["availability"] - session = SessionData() - session.platform = PlatformType( - data.presence["basicPresence"]["primaryPlatformInfo"]["platform"] - ) - - if session.platform in SUPPORTED_PLATFORMS: - session.status = data.presence.get("basicPresence", {}).get( - "primaryPlatformInfo" - )["onlineStatus"] - - game_title_info = data.presence.get("basicPresence", {}).get( - "gameTitleInfoList" + if "platform" in data.presence["basicPresence"]["primaryPlatformInfo"]: + primary_platform = PlatformType( + data.presence["basicPresence"]["primaryPlatformInfo"]["platform"] + ) + game_title_info: dict[str, Any] = next( + iter( + data.presence.get("basicPresence", {}).get("gameTitleInfoList", []) + ), + {}, + ) + status = data.presence.get("basicPresence", {}).get("primaryPlatformInfo")[ + "onlineStatus" + ] + title_format = ( + PlatformType(fmt) if (fmt := game_title_info.get("format")) else None ) - if game_title_info: - session.title_id = game_title_info[0]["npTitleId"] - session.title_name = game_title_info[0]["titleName"] - session.format = PlatformType(game_title_info[0]["format"]) - if session.format in {PlatformType.PS5, PlatformType.PSPC}: - session.media_image_url = game_title_info[0]["conceptIconUrl"] - else: - session.media_image_url = game_title_info[0]["npTitleIconUrl"] - - data.active_sessions[session.platform] = session + data.active_sessions[primary_platform] = SessionData( + platform=primary_platform, + status=status, + title_id=game_title_info.get("npTitleId"), + title_name=game_title_info.get("titleName"), + format=title_format, + media_image_url=( + game_title_info.get("conceptIconUrl") + or game_title_info.get("npTitleIconUrl") + ), + ) if self.legacy_profile: presence = self.legacy_profile["profile"].get("presences", []) From 850e04d9aaecde7f6a53bd7f728552bb5d2074dc Mon Sep 17 00:00:00 2001 From: wollew Date: Mon, 28 Jul 2025 13:15:59 +0200 Subject: [PATCH 191/235] Add binary sensor for rain detection for Velux windows that have them (#148275) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/velux/binary_sensor.py | 63 +++++++++++++++++++ homeassistant/components/velux/const.py | 2 +- tests/components/velux/conftest.py | 53 +++++++++++++++- tests/components/velux/test_binary_sensor.py | 50 +++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/velux/binary_sensor.py create mode 100644 tests/components/velux/test_binary_sensor.py diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py new file mode 100644 index 00000000000..e08d4bcf545 --- /dev/null +++ b/homeassistant/components/velux/binary_sensor.py @@ -0,0 +1,63 @@ +"""Support for rain sensors build into some velux windows.""" + +from __future__ import annotations + +from datetime import timedelta + +from pyvlx.exception import PyVLXException +from pyvlx.opening_device import OpeningDevice, Window + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, LOGGER +from .entity import VeluxEntity + +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up rain sensor(s) for Velux platform.""" + module = hass.data[DOMAIN][config.entry_id] + + async_add_entities( + VeluxRainSensor(node, config.entry_id) + for node in module.pyvlx.nodes + if isinstance(node, Window) and node.rain_sensor + ) + + +class VeluxRainSensor(VeluxEntity, BinarySensorEntity): + """Representation of a Velux rain sensor.""" + + node: Window + _attr_should_poll = True # the rain sensor / opening limitations needs polling unlike the rest of the Velux devices + _attr_entity_registry_enabled_default = False + _attr_device_class = BinarySensorDeviceClass.MOISTURE + + def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: + """Initialize VeluxRainSensor.""" + super().__init__(node, config_entry_id) + self._attr_unique_id = f"{self._attr_unique_id}_rain_sensor" + self._attr_name = f"{node.name} Rain sensor" + + async def async_update(self) -> None: + """Fetch the latest state from the device.""" + try: + limitation = await self.node.get_limitation() + except PyVLXException: + LOGGER.error("Error fetching limitation data for cover %s", self.name) + return + + # Velux windows with rain sensors report an opening limitation of 93 when rain is detected. + self._attr_is_on = limitation.min_value == 93 diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py index 49a762e87ca..46663383250 100644 --- a/homeassistant/components/velux/const.py +++ b/homeassistant/components/velux/const.py @@ -5,5 +5,5 @@ from logging import getLogger from homeassistant.const import Platform DOMAIN = "velux" -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE] LOGGER = getLogger(__package__) diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index c88a21d2bba..1b7066577ad 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -1,16 +1,18 @@ """Configuration for Velux tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.velux import DOMAIN +from homeassistant.components.velux.binary_sensor import Window from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from tests.common import MockConfigEntry +# Fixtures for the config flow tests @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -59,3 +61,52 @@ def mock_discovered_config_entry() -> MockConfigEntry: }, unique_id="VELUX_KLF_ABCD", ) + + +# fixtures for the binary sensor tests +@pytest.fixture +def mock_window() -> AsyncMock: + """Create a mock Velux window with a rain sensor.""" + window = AsyncMock(spec=Window, autospec=True) + window.name = "Test Window" + window.rain_sensor = True + window.serial_number = "123456789" + window.get_limitation.return_value = MagicMock(min_value=0) + return window + + +@pytest.fixture +def mock_pyvlx(mock_window: MagicMock) -> MagicMock: + """Create the library mock.""" + pyvlx = MagicMock() + pyvlx.nodes = [mock_window] + pyvlx.load_scenes = AsyncMock() + pyvlx.load_nodes = AsyncMock() + pyvlx.disconnect = AsyncMock() + return pyvlx + + +@pytest.fixture +def mock_module(mock_pyvlx: MagicMock) -> Generator[AsyncMock]: + """Create the Velux module mock.""" + with ( + patch( + "homeassistant.components.velux.VeluxModule", + autospec=True, + ) as mock_velux, + ): + module = mock_velux.return_value + module.pyvlx = mock_pyvlx + yield module + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "testhost", + CONF_PASSWORD: "testpw", + }, + ) diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py new file mode 100644 index 00000000000..8eb065a5a46 --- /dev/null +++ b/tests/components/velux/test_binary_sensor.py @@ -0,0 +1,50 @@ +"""Tests for the Velux binary sensor platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("mock_module") +async def test_rain_sensor_state( + hass: HomeAssistant, + mock_window: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the rain sensor.""" + mock_config_entry.add_to_hass(hass) + + test_entity_id = "binary_sensor.test_window_rain_sensor" + + with ( + patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]), + ): + # setup config entry + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # simulate no rain detected + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OFF + + # simulate rain detected + mock_window.get_limitation.return_value.min_value = 93 + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_ON From 4ad35e842150f7fef363d5c98b173846a62ec788 Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Mon, 28 Jul 2025 14:18:43 +0300 Subject: [PATCH 192/235] Add charging binary sensor to `ituran` (#149562) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ituran/__init__.py | 1 + .../components/ituran/binary_sensor.py | 75 +++++++++++++++++++ tests/components/ituran/conftest.py | 2 + .../ituran/snapshots/test_binary_sensor.ambr | 50 +++++++++++++ tests/components/ituran/test_binary_sensor.py | 73 ++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 homeassistant/components/ituran/binary_sensor.py create mode 100644 tests/components/ituran/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/ituran/test_binary_sensor.py diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py index bf9cff238cd..41392c5cee1 100644 --- a/homeassistant/components/ituran/__init__.py +++ b/homeassistant/components/ituran/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR, ] diff --git a/homeassistant/components/ituran/binary_sensor.py b/homeassistant/components/ituran/binary_sensor.py new file mode 100644 index 00000000000..8a18cca8968 --- /dev/null +++ b/homeassistant/components/ituran/binary_sensor.py @@ -0,0 +1,75 @@ +"""Binary sensors for Ituran vehicles.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from propcache.api import cached_property +from pyituran import Vehicle + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IturanBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Ituran binary sensor entity.""" + + value_fn: Callable[[Vehicle], bool] + supported_fn: Callable[[Vehicle], bool] = lambda _: True + + +BINARY_SENSOR_TYPES: list[IturanBinarySensorEntityDescription] = [ + IturanBinarySensorEntityDescription( + key="is_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda vehicle: vehicle.is_charging, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Ituran binary sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanBinarySensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() + for description in BINARY_SENSOR_TYPES + if description.supported_fn(vehicle) + ) + + +class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity): + """Ituran binary sensor.""" + + entity_description: IturanBinarySensorEntityDescription + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + description: IturanBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, license_plate, description.key) + self.entity_description = description + + @cached_property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.vehicle) diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index 1cb922b94e9..7582a2a6645 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -65,9 +65,11 @@ class MockVehicle: if is_electric_vehicle: self.battery_level = 42 self.battery_range = 150 + self.is_charging = True else: self.battery_level = 0 self.battery_range = 0 + self.is_charging = False @pytest.fixture diff --git a/tests/components/ituran/snapshots/test_binary_sensor.ambr b/tests/components/ituran/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..fed9f2b487c --- /dev/null +++ b/tests/components/ituran/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.mock_model_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-is_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'mock model Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_model_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ituran/test_binary_sensor.py b/tests/components/ituran/test_binary_sensor.py new file mode 100644 index 00000000000..1eb2fca6f4c --- /dev/null +++ b/tests/components/ituran/test_binary_sensor.py @@ -0,0 +1,73 @@ +"""Test the Ituran binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyituran.exceptions import IturanApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ituran.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is marked as unavailable when we can't reach the Ituran service.""" + entities = [ + "binary_sensor.mock_model_charging", + ] + + await setup_integration(hass, mock_config_entry) + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = IturanApiError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE From db1e6a0d986163ad3f9585514cf0a0ff93629c6e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:34:27 +0200 Subject: [PATCH 193/235] Add quality scale and set Silver for Tankerkoenig (#143418) --- .../components/tankerkoenig/manifest.json | 1 + .../tankerkoenig/quality_scale.yaml | 81 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/tankerkoenig/quality_scale.yaml diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 72248d006e0..5dc75e4cc90 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], + "quality_scale": "silver", "requirements": ["aiotankerkoenig==0.4.2"] } diff --git a/homeassistant/components/tankerkoenig/quality_scale.yaml b/homeassistant/components/tankerkoenig/quality_scale.yaml new file mode 100644 index 00000000000..666d927adb5 --- /dev/null +++ b/homeassistant/components/tankerkoenig/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom actions provided. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No custom actions provided. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No custom actions provided. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: It's a pure webservice, without real devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Each config entry represents one service entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + All possible changes are already covered by re-auth and options flow. + repair-issues: + status: exempt + comment: No repair issues implemented. + stale-devices: + status: exempt + comment: Each config entry represents one service entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index b42e1e415aa..def20d9d4cc 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -971,7 +971,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "tailscale", "tami4", "tank_utility", - "tankerkoenig", "tapsaff", "tasmota", "tautulli", @@ -2029,7 +2028,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "tailwind", "tami4", "tank_utility", - "tankerkoenig", "tapsaff", "tasmota", "tautulli", From bf05c23414116761f0dc4ab8a473f5bc7fa9c670 Mon Sep 17 00:00:00 2001 From: wittypluck Date: Mon, 28 Jul 2025 14:40:00 +0200 Subject: [PATCH 194/235] Update OpenWeatherMap config step description to clarify API key documentation (#146843) --- homeassistant/components/openweathermap/config_flow.py | 4 ++++ homeassistant/components/openweathermap/strings.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 4c66778119e..76a32af13b0 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -69,6 +69,10 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): title=user_input[CONF_NAME], data=data, options=options ) + description_placeholders["doc_url"] = ( + "https://www.home-assistant.io/integrations/openweathermap/" + ) + schema = vol.Schema( { vol.Required(CONF_API_KEY): str, diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 1aa161c87dc..51de5cf2244 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -17,7 +17,7 @@ "mode": "[%key:common::config_flow::data::mode%]", "name": "[%key:common::config_flow::data::name%]" }, - "description": "To generate API key go to https://openweathermap.org/appid" + "description": "To generate an API key, please refer to the [integration documentation]({doc_url})" } } }, From 48c4240a5ddc5657b2fbfca1858cb9391edabde6 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:48:45 -0400 Subject: [PATCH 195/235] Delete unused switch platform code (#149468) --- homeassistant/components/template/switch.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index bd271e4b17c..f5835f2d478 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -94,16 +94,6 @@ SWITCH_CONFIG_ENTRY_SCHEMA = SWITCH_COMMON_SCHEMA.extend( ) -def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: - """Rewrite option configuration to modern configuration.""" - option_config = {**option_config} - - if CONF_VALUE_TEMPLATE in option_config: - option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) - - return option_config - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, From 46d810b9f95b0b8bfd42019b64f3d806f52a1a4d Mon Sep 17 00:00:00 2001 From: hanwg Date: Mon, 28 Jul 2025 20:52:40 +0800 Subject: [PATCH 196/235] Better error handling when setting up config entry for Telegram bot (#149444) --- .../components/telegram_bot/config_flow.py | 8 ++++-- .../components/telegram_bot/strings.json | 1 + .../telegram_bot/test_config_flow.py | 26 ++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 8d3d9b0cd7b..c71d8a1ad1e 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -7,7 +7,7 @@ from types import MappingProxyType from typing import Any from telegram import Bot, ChatFullInfo -from telegram.error import BadRequest, InvalidToken, NetworkError +from telegram.error import BadRequest, InvalidToken, TelegramError import voluptuous as vol from homeassistant.config_entries import ( @@ -399,13 +399,17 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): placeholders[ERROR_FIELD] = "API key" placeholders[ERROR_MESSAGE] = str(err) return "Unknown bot" - except (ValueError, NetworkError) as err: + except ValueError as err: _LOGGER.warning("Invalid proxy") errors["base"] = "invalid_proxy_url" placeholders["proxy_url_error"] = str(err) placeholders[ERROR_FIELD] = "proxy url" placeholders[ERROR_MESSAGE] = str(err) return "Unknown bot" + except TelegramError as err: + errors["base"] = "telegram_error" + placeholders[ERROR_MESSAGE] = str(err) + return "Unknown bot" else: return user.full_name diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index df3de556efb..29bf51ecd0c 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -66,6 +66,7 @@ } }, "error": { + "telegram_error": "Error from Telegram: {error_message}", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "invalid_proxy_url": "{proxy_url_error}", "no_url_available": "URL is required since you have not configured an external URL in Home Assistant", diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 9a076016a32..0886246b7e1 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -221,10 +221,29 @@ async def test_create_entry(hass: HomeAssistant) -> None: # test: invalid proxy url + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PLATFORM: PLATFORM_WEBHOOKS, + CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, + }, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_proxy_url" + assert result["description_placeholders"]["error_field"] == "proxy url" + + # test: telegram error + with patch( "homeassistant.components.telegram_bot.config_flow.Bot.get_me", ) as mock_bot: - mock_bot.side_effect = NetworkError("mock invalid proxy") + mock_bot.side_effect = NetworkError("mock network error") result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -232,7 +251,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", SECTION_ADVANCED_SETTINGS: { - CONF_PROXY_URL: "invalid", + CONF_PROXY_URL: "https://proxy", }, }, ) @@ -240,7 +259,8 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == "invalid_proxy_url" + assert result["errors"]["base"] == "telegram_error" + assert result["description_placeholders"]["error_message"] == "mock network error" # test: valid input, to continue with webhooks step From a71eecaaa4d7a18e98dcf067da4cdf0379a21f12 Mon Sep 17 00:00:00 2001 From: Avery <130164016+avedor@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:10:55 -0400 Subject: [PATCH 197/235] Update datadog test logic (#149459) Co-authored-by: Joostlek --- .../components/datadog/config_flow.py | 6 +- tests/components/datadog/test_config_flow.py | 38 +++++- tests/components/datadog/test_init.py | 114 ++++++++++-------- 3 files changed, 98 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/datadog/config_flow.py b/homeassistant/components/datadog/config_flow.py index b4486b0967c..876b79b6019 100644 --- a/homeassistant/components/datadog/config_flow.py +++ b/homeassistant/components/datadog/config_flow.py @@ -36,14 +36,14 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN): """Handle user config flow.""" errors: dict[str, str] = {} if user_input: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) # Validate connection to Datadog Agent success = await validate_datadog_connection( self.hass, user_input, ) - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) if not success: errors["base"] = "cannot_connect" else: diff --git a/tests/components/datadog/test_config_flow.py b/tests/components/datadog/test_config_flow.py index 7950bb2c17d..1d181774fbe 100644 --- a/tests/components/datadog/test_config_flow.py +++ b/tests/components/datadog/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch from homeassistant.components import datadog -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir @@ -22,7 +22,7 @@ async def test_user_flow_success(hass: HomeAssistant) -> None: mock_dogstatsd.return_value = mock_instance result = await hass.config_entries.flow.async_init( - datadog.DOMAIN, context={"source": "user"} + datadog.DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM @@ -42,7 +42,7 @@ async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> Non side_effect=OSError("Connection failed"), ): result = await hass.config_entries.flow.async_init( - datadog.DOMAIN, context={"source": "user"} + datadog.DOMAIN, context={"source": SOURCE_USER} ) result2 = await hass.config_entries.flow.async_configure( @@ -62,6 +62,34 @@ async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> Non assert result3["options"] == MOCK_OPTIONS +async def test_user_flow_abort_already_configured_service( + hass: HomeAssistant, +) -> None: + """Abort user-initiated config flow if the same host/port is already configured.""" + existing_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: """Test that the options flow shows an error when connection fails.""" mock_entry = MockConfigEntry( @@ -221,9 +249,9 @@ async def test_import_flow_abort_already_configured_service( result = await hass.config_entries.flow.async_init( datadog.DOMAIN, - context={"source": "import"}, + context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG, ) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 73bce96d16c..3c22aaeee8f 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -8,57 +8,65 @@ from homeassistant.components.datadog import async_setup_entry from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import MOCK_DATA, MOCK_OPTIONS, create_mock_state -from tests.common import EVENT_STATE_CHANGED, MockConfigEntry, assert_setup_component +from tests.common import EVENT_STATE_CHANGED, MockConfigEntry async def test_invalid_config(hass: HomeAssistant) -> None: """Test invalid configuration.""" - with assert_setup_component(0): - assert not await async_setup_component( - hass, datadog.DOMAIN, {datadog.DOMAIN: {"host1": "host1"}} - ) + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={"host1": "host1"}, + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) async def test_datadog_setup_full(hass: HomeAssistant) -> None: """Test setup with all data.""" - config = {datadog.DOMAIN: {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} - with ( - patch( - "homeassistant.components.datadog.config_flow.DogStatsd" - ) as mock_dogstatsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_dogstatsd, ): - assert await async_setup_component(hass, datadog.DOMAIN, config) + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": "host", + "port": 123, + }, + options={ + "rate": 1, + "prefix": "foo", + }, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert mock_dogstatsd.call_count == 1 - assert mock_dogstatsd.call_args == mock.call("host", 123) + assert mock_dogstatsd.call_args == mock.call( + host="host", port=123, namespace="foo" + ) async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" with ( - patch( - "homeassistant.components.datadog.config_flow.DogStatsd" - ) as mock_dogstatsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_dogstatsd, ): - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "port": datadog.DEFAULT_PORT, - "prefix": datadog.DEFAULT_PREFIX, - } - }, + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) assert mock_dogstatsd.call_count == 1 - assert mock_dogstatsd.call_args == mock.call("host", 8125) + assert mock_dogstatsd.call_args == mock.call( + host="localhost", port=8125, namespace="hass" + ) async def test_logbook_entry(hass: HomeAssistant) -> None: @@ -70,24 +78,24 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: ), ): mock_statsd = mock_statsd_class.return_value - - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "port": datadog.DEFAULT_PORT, - "rate": datadog.DEFAULT_RATE, - "prefix": datadog.DEFAULT_PREFIX, - } + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": datadog.DEFAULT_HOST, + "port": datadog.DEFAULT_PORT, + }, + options={ + "rate": datadog.DEFAULT_RATE, + "prefix": datadog.DEFAULT_PREFIX, }, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) event = { "domain": "automation", "entity_id": "sensor.foo.bar", - "message": "foo bar biz", + "message": "foo bar baz", "name": "triggered something", } hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, event) @@ -110,18 +118,16 @@ async def test_state_changed(hass: HomeAssistant) -> None: ), ): mock_statsd = mock_statsd_class.return_value - - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "prefix": "ha", - "rate": datadog.DEFAULT_RATE, - } + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": "host", + "port": datadog.DEFAULT_PORT, }, + options={"prefix": "ha", "rate": datadog.DEFAULT_RATE}, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) valid = {"1": 1, "1.0": 1.0, STATE_ON: 1, STATE_OFF: 0} @@ -191,14 +197,18 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_state_changed_skips_unknown(hass: HomeAssistant) -> None: """Test state_changed_listener skips None and unknown states.""" - entry = MockConfigEntry(domain=datadog.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS) - entry.add_to_hass(hass) - with ( patch( "homeassistant.components.datadog.config_flow.DogStatsd" ) as mock_dogstatsd, ): + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + entry.add_to_hass(hass) + await async_setup_entry(hass, entry) # Test None state From 2a5448835fce78e12549ca8ad538fba15e78b8ed Mon Sep 17 00:00:00 2001 From: jennoian <39549658+jennoian@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:37:37 +0100 Subject: [PATCH 198/235] Add Vacuum support to smartthings (#148724) Co-authored-by: Joostlek Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/vacuum.py | 95 +++++++++++++ .../device_status/da_rvc_map_01011.json | 2 +- .../smartthings/snapshots/test_vacuum.ambr | 99 +++++++++++++ tests/components/smartthings/test_vacuum.py | 133 ++++++++++++++++++ 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smartthings/vacuum.py create mode 100644 tests/components/smartthings/snapshots/test_vacuum.ambr create mode 100644 tests/components/smartthings/test_vacuum.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e4259e4182c..9c7621037c7 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -103,6 +103,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VACUUM, Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/smartthings/vacuum.py b/homeassistant/components/smartthings/vacuum.py new file mode 100644 index 00000000000..59152842150 --- /dev/null +++ b/homeassistant/components/smartthings/vacuum.py @@ -0,0 +1,95 @@ +"""SmartThings vacuum platform.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pysmartthings import Attribute, Command, SmartThings +from pysmartthings.capability import Capability + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up vacuum entities from SmartThings devices.""" + entry_data = entry.runtime_data + async_add_entities( + SamsungJetBotVacuum(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE in device.status[MAIN] + ) + + +class SamsungJetBotVacuum(SmartThingsEntity, StateVacuumEntity): + """Representation of a Vacuum.""" + + _attr_name = None + _attr_supported_features = ( + VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STATE + ) + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the Samsung robot cleaner vacuum entity.""" + super().__init__( + client, + device, + {Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE}, + ) + + @property + def activity(self) -> VacuumActivity | None: + """Return the current vacuum activity based on operating state.""" + status = self.get_attribute_value( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + ) + + return { + "cleaning": VacuumActivity.CLEANING, + "homing": VacuumActivity.RETURNING, + "idle": VacuumActivity.IDLE, + "paused": VacuumActivity.PAUSED, + "docked": VacuumActivity.DOCKED, + "error": VacuumActivity.ERROR, + "charging": VacuumActivity.DOCKED, + }.get(status) + + async def async_start(self) -> None: + """Start the vacuum's operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.START, + ) + + async def async_pause(self) -> None: + """Pause the vacuum's current operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, Command.PAUSE + ) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return the vacuum to its base.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.RETURN_TO_HOME, + ) diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json index 14244935308..686207f67d2 100644 --- a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json @@ -878,7 +878,7 @@ "timestamp": "2025-06-20T14:12:58.012Z" }, "operatingState": { - "value": "dryingMop", + "value": "charging", "timestamp": "2025-07-10T09:52:40.510Z" }, "cleaningStep": { diff --git a/tests/components/smartthings/snapshots/test_vacuum.ambr b/tests/components/smartthings/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..59bbae2b3e7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_vacuum.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/smartthings/test_vacuum.py b/tests/components/smartthings/test_vacuum.py new file mode 100644 index 00000000000..6e2406625eb --- /dev/null +++ b/tests/components/smartthings/test_vacuum.py @@ -0,0 +1,133 @@ +"""Test for the SmartThings vacuum platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.VACUUM) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_START, Command.START), + (SERVICE_PAUSE, Command.PAUSE), + (SERVICE_RETURN_TO_BASE, Command.RETURN_TO_HOME), + ], +) +async def test_vacuum_actions( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test vacuum actions.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VACUUM_DOMAIN, + action, + {ATTR_ENTITY_ID: "vacuum.robot_vacuum"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + command, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_update( + hass, + devices, + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + "error", + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.ERROR + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.OFFLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.ONLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE From d088fccb8833985baf6441cdd675ae4335fd48f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?alvi=20kazi=20=F0=9F=87=A7=F0=9F=87=A9?= Date: Mon, 28 Jul 2025 22:51:07 +0900 Subject: [PATCH 199/235] VeSync: add support for LAP-V102S-WJP air purifier (#149102) --- homeassistant/components/vesync/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 08db4463e07..6d818b463d8 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -129,6 +129,7 @@ SKU_TO_BASE_DEVICE = { "LAP-V102S-WEU": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-WUK": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-AUSR": "Vital100S", # Alt ID Model Vital100S + "LAP-V102S-WJP": "Vital100S", # Alt ID Model Vital100S "EverestAir": "EverestAir", "LAP-EL551S-AUS": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir From 386f709fd3814d0a014ba18e60a5256c92a5e1e0 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:00:22 +0300 Subject: [PATCH 200/235] Osoenergy holiday mode services (#149430) Co-authored-by: Joost Lekkerkerker --- .../components/osoenergy/water_heater.py | 17 ++++++++- tests/components/osoenergy/conftest.py | 2 ++ .../osoenergy/fixtures/water_heater.json | 3 +- .../snapshots/test_water_heater.ambr | 5 +-- .../components/osoenergy/test_water_heater.py | 36 +++++++++++++++++++ 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index 07820ee97d5..c271330bacd 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -164,7 +164,9 @@ class OSOEnergyWaterHeater( _attr_name = None _attr_supported_features = ( - WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.ON_OFF + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF ) _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -203,6 +205,11 @@ class OSOEnergyWaterHeater( """Return the current temperature of the heater.""" return self.entity_data.current_temperature + @property + def is_away_mode_on(self) -> bool: + """Return if the heater is in away mode.""" + return self.entity_data.isInPowerSave + @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" @@ -228,6 +235,14 @@ class OSOEnergyWaterHeater( """Return the maximum temperature.""" return self.entity_data.max_temperature + async def async_turn_away_mode_on(self) -> None: + """Turn on away mode.""" + await self.osoenergy.hotwater.enable_holiday_mode(self.entity_data) + + async def async_turn_away_mode_off(self) -> None: + """Turn off away mode.""" + await self.osoenergy.hotwater.disable_holiday_mode(self.entity_data) + async def async_turn_on(self, **kwargs) -> None: """Turn on hotwater.""" await self.osoenergy.hotwater.turn_on(self.entity_data, True) diff --git a/tests/components/osoenergy/conftest.py b/tests/components/osoenergy/conftest.py index bb14fec0241..915761ba6d3 100644 --- a/tests/components/osoenergy/conftest.py +++ b/tests/components/osoenergy/conftest.py @@ -74,6 +74,8 @@ async def mock_osoenergy_client(mock_water_heater) -> Generator[AsyncMock]: mock_client().session = mock_session mock_hotwater = MagicMock() + mock_hotwater.enable_holiday_mode = AsyncMock(return_value=True) + mock_hotwater.disable_holiday_mode = AsyncMock(return_value=True) mock_hotwater.get_water_heater = AsyncMock(return_value=mock_water_heater) mock_hotwater.set_profile = AsyncMock(return_value=True) mock_hotwater.set_v40_min = AsyncMock(return_value=True) diff --git a/tests/components/osoenergy/fixtures/water_heater.json b/tests/components/osoenergy/fixtures/water_heater.json index 82bdafb5d8a..4c2b7abbb41 100644 --- a/tests/components/osoenergy/fixtures/water_heater.json +++ b/tests/components/osoenergy/fixtures/water_heater.json @@ -16,5 +16,6 @@ "profile": [ 10, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60 - ] + ], + "isInPowerSave": false } diff --git a/tests/components/osoenergy/snapshots/test_water_heater.ambr b/tests/components/osoenergy/snapshots/test_water_heater.ambr index 18c434d133b..208fd3b2aa3 100644 --- a/tests/components/osoenergy/snapshots/test_water_heater.ambr +++ b/tests/components/osoenergy/snapshots/test_water_heater.ambr @@ -31,7 +31,7 @@ 'platform': 'osoenergy', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'osoenergy_water_heater', 'unit_of_measurement': None, @@ -40,11 +40,12 @@ # name: test_water_heater[water_heater.test_device-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'away_mode': 'off', 'current_temperature': 60, 'friendly_name': 'TEST DEVICE', 'max_temp': 75, 'min_temp': 10, - 'supported_features': , + 'supported_features': , 'target_temp_high': 63, 'target_temp_low': 57, 'temperature': 60, diff --git a/tests/components/osoenergy/test_water_heater.py b/tests/components/osoenergy/test_water_heater.py index fd27975c938..270fc3c58f0 100644 --- a/tests/components/osoenergy/test_water_heater.py +++ b/tests/components/osoenergy/test_water_heater.py @@ -14,7 +14,9 @@ from homeassistant.components.osoenergy.water_heater import ( SERVICE_SET_V40MIN, ) from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, SERVICE_SET_TEMPERATURE, ) from homeassistant.config_entries import ConfigEntry @@ -274,3 +276,37 @@ async def test_oso_turn_off( ) mock_osoenergy_client().hotwater.turn_off.assert_called_once_with(ANY, False) + + +async def test_turn_away_mode_on( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test turning the heater away mode on.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + {ATTR_ENTITY_ID: "water_heater.test_device", ATTR_AWAY_MODE: "on"}, + blocking=True, + ) + + mock_osoenergy_client().hotwater.enable_holiday_mode.assert_called_once_with(ANY) + + +async def test_turn_away_mode_off( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test turning the heater away mode off.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + {ATTR_ENTITY_ID: "water_heater.test_device", ATTR_AWAY_MODE: "off"}, + blocking=True, + ) + + mock_osoenergy_client().hotwater.disable_holiday_mode.assert_called_once_with(ANY) From 8fc8220924f318bcd49468a6e9be2e84b0272daf Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 28 Jul 2025 10:06:15 -0400 Subject: [PATCH 201/235] Teach Hydrawise to auto-add/remove devices (#149547) Co-authored-by: Joost Lekkerkerker --- .../components/hydrawise/binary_sensor.py | 57 ++++++---- homeassistant/components/hydrawise/const.py | 1 + .../components/hydrawise/coordinator.py | 92 ++++++++++++++- homeassistant/components/hydrawise/entity.py | 6 +- homeassistant/components/hydrawise/sensor.py | 83 +++++++++----- homeassistant/components/hydrawise/switch.py | 23 ++-- homeassistant/components/hydrawise/valve.py | 22 +++- tests/components/hydrawise/test_init.py | 106 +++++++++++++++++- 8 files changed, 321 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 45537a2cc73..f2177d2144a 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime -from pydrawise import Zone +from pydrawise import Controller, Zone import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -81,31 +81,46 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise binary_sensor platform.""" coordinators = config_entry.runtime_data - entities: list[HydrawiseBinarySensor] = [] - for controller in coordinators.main.data.controllers.values(): - entities.extend( - HydrawiseBinarySensor(coordinators.main, description, controller) - for description in CONTROLLER_BINARY_SENSORS - ) - entities.extend( - HydrawiseBinarySensor( - coordinators.main, - description, - controller, - sensor_id=sensor.id, + + def _add_new_controllers(controllers: Iterable[Controller]) -> None: + entities: list[HydrawiseBinarySensor] = [] + for controller in controllers: + entities.extend( + HydrawiseBinarySensor(coordinators.main, description, controller) + for description in CONTROLLER_BINARY_SENSORS ) - for sensor in controller.sensors - for description in RAIN_SENSOR_BINARY_SENSOR - if "rain sensor" in sensor.model.name.lower() - ) - entities.extend( + entities.extend( + HydrawiseBinarySensor( + coordinators.main, + description, + controller, + sensor_id=sensor.id, + ) + for sensor in controller.sensors + for description in RAIN_SENSOR_BINARY_SENSOR + if "rain sensor" in sensor.model.name.lower() + ) + async_add_entities(entities) + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( HydrawiseZoneBinarySensor( coordinators.main, description, controller, zone_id=zone.id ) - for zone in controller.zones + for zone, controller in zones for description in ZONE_BINARY_SENSORS ) - async_add_entities(entities) + + _add_new_controllers(coordinators.main.data.controllers.values()) + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] + ) + coordinators.main.new_controllers_callbacks.append(_add_new_controllers) + coordinators.main.new_zones_callbacks.append(_add_new_zones) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_RESUME, None, "resume") platform.async_register_entity_service( diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index beaf450a586..502fd14cfbd 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -13,6 +13,7 @@ DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = timedelta(minutes=15) MANUFACTURER = "Hydrawise" +MODEL_ZONE = "Zone" MAIN_SCAN_INTERVAL = timedelta(minutes=5) WATER_USE_SCAN_INTERVAL = timedelta(minutes=60) diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 15d286801f9..308ffc23e36 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,17 +2,26 @@ from __future__ import annotations +from collections.abc import Callable, Iterable from dataclasses import dataclass, field from pydrawise import HydrawiseBase from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import now -from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL +from .const import ( + DOMAIN, + LOGGER, + MAIN_SCAN_INTERVAL, + MODEL_ZONE, + WATER_USE_SCAN_INTERVAL, +) type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators] @@ -24,6 +33,7 @@ class HydrawiseData: user: User controllers: dict[int, Controller] = field(default_factory=dict) zones: dict[int, Zone] = field(default_factory=dict) + zone_id_to_controller: dict[int, Controller] = field(default_factory=dict) sensors: dict[int, Sensor] = field(default_factory=dict) daily_water_summary: dict[int, ControllerWaterUseSummary] = field( default_factory=dict @@ -68,6 +78,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): update_interval=MAIN_SCAN_INTERVAL, ) self.api = api + self.new_controllers_callbacks: list[ + Callable[[Iterable[Controller]], None] + ] = [] + self.new_zones_callbacks: list[ + Callable[[Iterable[tuple[Zone, Controller]]], None] + ] = [] + self.async_add_listener(self._add_remove_zones) async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" @@ -80,10 +97,81 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): controller.zones = await self.api.get_zones(controller) for zone in controller.zones: data.zones[zone.id] = zone + data.zone_id_to_controller[zone.id] = controller for sensor in controller.sensors: data.sensors[sensor.id] = sensor return data + @callback + def _add_remove_zones(self) -> None: + """Add newly discovered zones and remove nonexistent ones.""" + if self.data is None: + # Likely a setup error; ignore. + # Despite what mypy thinks, this is still reachable. Without this check, + # the test_connect_retry test in test_init.py fails. + return # type: ignore[unreachable] + + device_registry = dr.async_get(self.hass) + devices = dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id + ) + previous_zones: set[str] = set() + previous_zones_by_id: dict[str, DeviceEntry] = {} + previous_controllers: set[str] = set() + previous_controllers_by_id: dict[str, DeviceEntry] = {} + for device in devices: + for domain, identifier in device.identifiers: + if domain == DOMAIN: + if device.model == MODEL_ZONE: + previous_zones.add(identifier) + previous_zones_by_id[identifier] = device + else: + previous_controllers.add(identifier) + previous_controllers_by_id[identifier] = device + continue + + current_zones = {str(zone_id) for zone_id in self.data.zones} + current_controllers = { + str(controller_id) for controller_id in self.data.controllers + } + + if removed_zones := previous_zones - current_zones: + LOGGER.debug("Removed zones: %s", ", ".join(removed_zones)) + for zone_id in removed_zones: + device_registry.async_update_device( + device_id=previous_zones_by_id[zone_id].id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if removed_controllers := previous_controllers - current_controllers: + LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers)) + for controller_id in removed_controllers: + device_registry.async_update_device( + device_id=previous_controllers_by_id[controller_id].id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if new_controller_ids := current_controllers - previous_controllers: + LOGGER.debug("New controllers found: %s", ", ".join(new_controller_ids)) + new_controllers = [ + self.data.controllers[controller_id] + for controller_id in map(int, new_controller_ids) + ] + for new_controller_callback in self.new_controllers_callbacks: + new_controller_callback(new_controllers) + + if new_zone_ids := current_zones - previous_zones: + LOGGER.debug("New zones found: %s", ", ".join(new_zone_ids)) + new_zones = [ + ( + self.data.zones[zone_id], + self.data.zone_id_to_controller[zone_id], + ) + for zone_id in map(int, new_zone_ids) + ] + for new_zone_callback in self.new_zones_callbacks: + new_zone_callback(new_zones) + class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): """Data Update Coordinator for Hydrawise Water Use. diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 67dd6375b0e..58153d43634 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER, MODEL_ZONE from .coordinator import HydrawiseDataUpdateCoordinator @@ -40,7 +40,9 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): identifiers={(DOMAIN, self._device_id)}, name=self.zone.name if zone_id is not None else controller.name, model=( - "Zone" if zone_id is not None else controller.hardware.model.description + MODEL_ZONE + if zone_id is not None + else controller.hardware.model.description ), manufacturer=MANUFACTURER, ) diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index ce0bc5a0997..3a04a587bb4 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise.schema import ControllerWaterUseSummary +from pydrawise.schema import Controller, ControllerWaterUseSummary, Zone from homeassistant.components.sensor import ( SensorDeviceClass, @@ -31,7 +31,9 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription): def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary: - return sensor.coordinator.data.daily_water_summary[sensor.controller.id] + return sensor.coordinator.data.daily_water_summary.get( + sensor.controller.id, ControllerWaterUseSummary() + ) WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( @@ -133,44 +135,65 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise sensor platform.""" coordinators = config_entry.runtime_data - entities: list[HydrawiseSensor] = [] - for controller in coordinators.main.data.controllers.values(): - entities.extend( - HydrawiseSensor(coordinators.water_use, description, controller) - for description in WATER_USE_CONTROLLER_SENSORS + + def _has_flow_sensor(controller: Controller) -> bool: + daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get( + controller.id, ControllerWaterUseSummary() ) - entities.extend( - HydrawiseSensor( - coordinators.water_use, description, controller, zone_id=zone.id - ) - for zone in controller.zones - for description in WATER_USE_ZONE_SENSORS - ) - entities.extend( - HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id) - for zone in controller.zones - for description in ZONE_SENSORS - ) - if ( - coordinators.water_use.data.daily_water_summary[controller.id].total_use - is not None - ): - # we have a flow sensor for this controller + return daily_water_use_summary.total_use is not None + + def _add_new_controllers(controllers: Iterable[Controller]) -> None: + entities: list[HydrawiseSensor] = [] + for controller in controllers: entities.extend( HydrawiseSensor(coordinators.water_use, description, controller) - for description in FLOW_CONTROLLER_SENSORS + for description in WATER_USE_CONTROLLER_SENSORS ) - entities.extend( + if _has_flow_sensor(controller): + entities.extend( + HydrawiseSensor(coordinators.water_use, description, controller) + for description in FLOW_CONTROLLER_SENSORS + ) + async_add_entities(entities) + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + [ + HydrawiseSensor( + coordinators.water_use, description, controller, zone_id=zone.id + ) + for zone, controller in zones + for description in WATER_USE_ZONE_SENSORS + ] + + [ + HydrawiseSensor( + coordinators.main, description, controller, zone_id=zone.id + ) + for zone, controller in zones + for description in ZONE_SENSORS + ] + + [ HydrawiseSensor( coordinators.water_use, description, controller, zone_id=zone.id, ) - for zone in controller.zones + for zone, controller in zones for description in FLOW_ZONE_SENSORS - ) - async_add_entities(entities) + if _has_flow_sensor(controller) + ] + ) + + _add_new_controllers(coordinators.main.data.controllers.values()) + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] + ) + coordinators.main.new_controllers_callbacks.append(_add_new_controllers) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseSensor(HydrawiseEntity, SensorEntity): diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 7a77f27265b..238e249e1f6 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise import HydrawiseBase, Zone +from pydrawise import Controller, HydrawiseBase, Zone from homeassistant.components.switch import ( SwitchDeviceClass, @@ -66,12 +66,21 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise switch platform.""" coordinators = config_entry.runtime_data - async_add_entities( - HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) - for controller in coordinators.main.data.controllers.values() - for zone in controller.zones - for description in SWITCH_TYPES + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) + for zone, controller in zones + for description in SWITCH_TYPES + ) + + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] ) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 85a91c807b2..56dd56e7d21 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Any -from pydrawise.schema import Zone +from pydrawise.schema import Controller, Zone from homeassistant.components.valve import ( ValveDeviceClass, @@ -33,12 +34,21 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise valve platform.""" coordinators = config_entry.runtime_data - async_add_entities( - HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) - for controller in coordinators.main.data.controllers.values() - for zone in controller.zones - for description in VALVE_TYPES + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) + for zone, controller in zones + for description in VALVE_TYPES + ) + + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] ) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseValve(HydrawiseEntity, ValveEntity): diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 8ec3c3da648..31e86589543 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,13 +1,19 @@ """Tests for the Hydrawise integration.""" +from copy import deepcopy from unittest.mock import AsyncMock from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Controller, User, Zone +from homeassistant.components.hydrawise.const import DOMAIN, MAIN_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_connect_retry( @@ -32,3 +38,101 @@ async def test_update_version( # Make sure reauth flow has been initiated assert any(mock_config_entry_legacy.async_get_active_flows(hass, {"reauth"})) + + +async def test_auto_add_devices( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + user: User, + controller: Controller, + zones: list[Zone], + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are auto-added to the device registry.""" + device = device_registry.async_get_device( + identifiers={(DOMAIN, str(controller.id))} + ) + assert device is not None + for zone in zones: + zone_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(zone.id))} + ) + assert zone_device is not None + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + # 1 controller + 2 zones + assert len(all_devices) == 3 + + controller2 = deepcopy(controller) + controller2.id += 10 + controller2.name += " 2" + controller2.sensors = [] + + zones2 = deepcopy(zones) + for zone in zones2: + zone.id += 10 + zone.name += " 2" + + user.controllers = [controller, controller2] + mock_pydrawise.get_zones.side_effect = [zones, zones2] + + # Make the coordinator refresh data. + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + new_controller_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(controller2.id))} + ) + assert new_controller_device is not None + for zone in zones2: + new_zone_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(zone.id))} + ) + assert new_zone_device is not None + + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + # 2 controllers + 4 zones + assert len(all_devices) == 6 + + +async def test_auto_remove_devices( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: MockConfigEntry, + user: User, + controller: Controller, + zones: list[Zone], + freezer: FrozenDateTimeFactory, +) -> None: + """Test old devices are auto-removed from the device registry.""" + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))}) + is not None + ) + for zone in zones: + device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))}) + assert device is not None + + user.controllers = [] + # Make the coordinator refresh data. + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))}) + is None + ) + for zone in zones: + device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))}) + assert device is None + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + assert len(all_devices) == 0 From 96529ec245b2969891ea7c256d2c8657b0e91775 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 28 Jul 2025 16:12:53 +0200 Subject: [PATCH 202/235] Add Reolink pre-recording entities (#149522) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- homeassistant/components/reolink/icons.json | 12 +++++++ homeassistant/components/reolink/number.py | 34 ++++++++++++++++++- homeassistant/components/reolink/select.py | 14 ++++++++ homeassistant/components/reolink/strings.json | 12 +++++++ homeassistant/components/reolink/switch.py | 9 +++++ .../reolink/snapshots/test_diagnostics.ambr | 4 +++ 6 files changed, 84 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 0c9831af2a8..597a3372400 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -300,6 +300,12 @@ }, "image_hue": { "default": "mdi:image-edit" + }, + "pre_record_time": { + "default": "mdi:history" + }, + "pre_record_battery_stop": { + "default": "mdi:history" } }, "select": { @@ -390,6 +396,9 @@ "packing_time": { "default": "mdi:record-rec" }, + "pre_record_fps": { + "default": "mdi:history" + }, "post_rec_time": { "default": "mdi:record-rec" } @@ -470,6 +479,9 @@ "manual_record": { "default": "mdi:record-rec" }, + "pre_record": { + "default": "mdi:history" + }, "hub_ringtone_on_event": { "default": "mdi:music-note" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 2de2468ca3d..d0222b0cffb 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -542,6 +542,38 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.image_hue(ch), method=lambda api, ch, value: api.set_image(ch, hue=int(value)), ), + ReolinkNumberEntityDescription( + key="pre_record_time", + cmd_key="594", + translation_key="pre_record_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=2, + native_max_value=10, + native_unit_of_measurement=UnitOfTime.SECONDS, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_time(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, time=int(value) + ), + ), + ReolinkNumberEntityDescription( + key="pre_record_battery_stop", + cmd_key="594", + translation_key="pre_record_battery_stop", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=10, + native_max_value=80, + native_unit_of_measurement=PERCENTAGE, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_battery_stop(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, battery_stop=int(value) + ), + ), ) SMART_AI_NUMBER_ENTITIES = ( diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index d55cf9386f9..242ea784cd9 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -250,6 +250,20 @@ SELECT_ENTITIES = ( value=lambda api, ch: str(api.bit_rate(ch, "sub")), method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"), ), + ReolinkSelectEntityDescription( + key="pre_record_fps", + cmd_key="594", + translation_key="pre_record_fps", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + unit_of_measurement=UnitOfFrequency.HERTZ, + get_options=["1", "2", "5"], + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: str(api.baichuan.pre_record_fps(ch)), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, fps=int(value) + ), + ), ReolinkSelectEntityDescription( key="post_rec_time", cmd_key="GetRec", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 1b155af6a4d..7e8bf94eeae 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -654,6 +654,12 @@ }, "image_hue": { "name": "Image hue" + }, + "pre_record_time": { + "name": "Pre-recording time" + }, + "pre_record_battery_stop": { + "name": "Pre-recording stop battery level" } }, "select": { @@ -858,6 +864,9 @@ "packing_time": { "name": "Recording packing time" }, + "pre_record_fps": { + "name": "Pre-recording frame rate" + }, "post_rec_time": { "name": "Post-recording time" } @@ -946,6 +955,9 @@ "manual_record": { "name": "Manual record" }, + "pre_record": { + "name": "Pre-recording" + }, "hub_ringtone_on_event": { "name": "Hub ringtone on event" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 47b14f7f4ad..00934bc9777 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -169,6 +169,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.manual_record_enabled(ch), method=lambda api, ch, value: api.set_manual_record(ch, value), ), + ReolinkSwitchEntityDescription( + key="pre_record", + cmd_key="594", + translation_key="pre_record", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_enabled(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording(ch, enabled=value), + ), ReolinkSwitchEntityDescription( key="buzzer", cmd_key="GetBuzzerAlarmV20", diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 25a9dc299aa..c2b059d658b 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -77,6 +77,10 @@ '0': 1, 'null': 1, }), + '594': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, From 9a364ec72914bb7e226394d8f8bd32c0dc30cd39 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 28 Jul 2025 16:13:39 +0200 Subject: [PATCH 203/235] Fix Z-Wave removal of devices when connected to unknown controller (#149339) --- homeassistant/components/zwave_js/__init__.py | 121 ++++++++++-------- homeassistant/components/zwave_js/repairs.py | 1 + tests/components/zwave_js/test_config_flow.py | 50 ++++++-- tests/components/zwave_js/test_init.py | 8 +- tests/components/zwave_js/test_repairs.py | 33 ++++- 5 files changed, 140 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 982525be778..d754419c94c 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -277,39 +277,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> b # and we'll handle the clean up below. await driver_events.setup(driver) - if (old_unique_id := entry.unique_id) is not None and old_unique_id != ( - new_unique_id := str(driver.controller.home_id) - ): - device_registry = dr.async_get(hass) - controller_model = "Unknown model" - if ( - (own_node := driver.controller.own_node) - and ( - controller_device_entry := device_registry.async_get_device( - identifiers={get_device_id(driver, own_node)} - ) - ) - and (model := controller_device_entry.model) - ): - controller_model = model - async_create_issue( - hass, - DOMAIN, - f"migrate_unique_id.{entry.entry_id}", - data={ - "config_entry_id": entry.entry_id, - "config_entry_title": entry.title, - "controller_model": controller_model, - "new_unique_id": new_unique_id, - "old_unique_id": old_unique_id, - }, - is_fixable=True, - severity=IssueSeverity.ERROR, - translation_key="migrate_unique_id", - ) - else: - async_delete_issue(hass, DOMAIN, f"migrate_unique_id.{entry.entry_id}") - # If the listen task is already failed, we need to raise ConfigEntryNotReady if listen_task.done(): listen_error, error_message = _get_listen_task_error(listen_task) @@ -387,28 +354,6 @@ class DriverEvents: self.hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) ) - # Check for nodes that no longer exist and remove them - stored_devices = dr.async_entries_for_config_entry( - self.dev_reg, self.config_entry.entry_id - ) - known_devices = [ - self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) - for node in controller.nodes.values() - ] - provisioned_devices = [ - self.dev_reg.async_get(entry.additional_properties["device_id"]) - for entry in await controller.async_get_provisioning_entries() - if entry.additional_properties - and "device_id" in entry.additional_properties - ] - - # Devices that are in the device registry that are not known by the controller - # can be removed - if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): - for device in stored_devices: - if device not in known_devices and device not in provisioned_devices: - self.dev_reg.async_remove_device(device.id) - # run discovery on controller node if controller.own_node: await self.controller_events.async_on_node_added(controller.own_node) @@ -443,6 +388,72 @@ class DriverEvents: controller.on("identify", self.controller_events.async_on_identify) ) + if ( + old_unique_id := self.config_entry.unique_id + ) is not None and old_unique_id != ( + new_unique_id := str(driver.controller.home_id) + ): + device_registry = dr.async_get(self.hass) + controller_model = "Unknown model" + if ( + (own_node := driver.controller.own_node) + and ( + controller_device_entry := device_registry.async_get_device( + identifiers={get_device_id(driver, own_node)} + ) + ) + and (model := controller_device_entry.model) + ): + controller_model = model + + # Do not clean up old stale devices if an unknown controller is connected. + data = {**self.config_entry.data, CONF_KEEP_OLD_DEVICES: True} + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + async_create_issue( + self.hass, + DOMAIN, + f"migrate_unique_id.{self.config_entry.entry_id}", + data={ + "config_entry_id": self.config_entry.entry_id, + "config_entry_title": self.config_entry.title, + "controller_model": controller_model, + "new_unique_id": new_unique_id, + "old_unique_id": old_unique_id, + }, + is_fixable=True, + severity=IssueSeverity.ERROR, + translation_key="migrate_unique_id", + ) + else: + data = self.config_entry.data.copy() + data.pop(CONF_KEEP_OLD_DEVICES, None) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + async_delete_issue( + self.hass, DOMAIN, f"migrate_unique_id.{self.config_entry.entry_id}" + ) + + # Check for nodes that no longer exist and remove them + stored_devices = dr.async_entries_for_config_entry( + self.dev_reg, self.config_entry.entry_id + ) + known_devices = [ + self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) + for node in controller.nodes.values() + ] + provisioned_devices = [ + self.dev_reg.async_get(entry.additional_properties["device_id"]) + for entry in await controller.async_get_provisioning_entries() + if entry.additional_properties + and "device_id" in entry.additional_properties + ] + + # Devices that are in the device registry that are not known by the controller + # can be removed + if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): + for device in stored_devices: + if device not in known_devices and device not in provisioned_devices: + self.dev_reg.async_remove_device(device.id) + class ControllerEvents: """Represent controller events. diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index f1deb91d869..072a330a7bd 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -90,6 +90,7 @@ class MigrateUniqueIDFlow(RepairsFlow): config_entry, unique_id=self.description_placeholders["new_unique_id"], ) + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) return self.async_create_entry(data={}) return self.async_show_form( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index c708b1c9d66..15ec6959caf 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -883,9 +883,9 @@ async def test_usb_discovery_migration( addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -893,6 +893,11 @@ async def test_usb_discovery_migration( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -914,6 +919,7 @@ async def test_usb_discovery_migration( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) @@ -967,7 +973,7 @@ async def test_usb_discovery_migration( assert restart_addon.call_args == call("core_zwave_js") version_info = get_server_version.return_value - version_info.home_id = 5678 + version_info.home_id = 3245146787 result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -991,7 +997,7 @@ async def test_usb_discovery_migration( assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["use_addon"] is True assert "keep_old_devices" not in entry.data - assert entry.unique_id == "5678" + assert entry.unique_id == "3245146787" @pytest.mark.usefixtures("supervisor", "addon_running") @@ -1008,9 +1014,9 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -1018,6 +1024,11 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -1039,6 +1050,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) @@ -1113,7 +1125,8 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert entry.unique_id == "1234" + assert "keep_old_devices" in entry.data @pytest.mark.usefixtures("supervisor", "addon_installed") @@ -3011,6 +3024,7 @@ async def test_reconfigure_different_device( entry = integration data = {**entry.data, **entry_data} hass.config_entries.async_update_entry(entry, data=data, unique_id="1234") + client.driver.controller.data["homeId"] = 1234 assert entry.data["url"] == "ws://test.org" @@ -3164,6 +3178,7 @@ async def test_reconfigure_addon_restart_failed( entry = integration data = {**entry.data, **entry_data} hass.config_entries.async_update_entry(entry, data=data, unique_id="1234") + client.driver.controller.data["homeId"] = 1234 assert entry.data["url"] == "ws://test.org" @@ -3554,10 +3569,12 @@ async def test_reconfigure_migrate_low_sdk_version( ( "restore_server_version_side_effect", "final_unique_id", + "keep_old_devices", + "device_entry_count", ), [ - (None, "3245146787"), - (aiohttp.ClientError("Boom"), "5678"), + (None, "3245146787", False, 2), + (aiohttp.ClientError("Boom"), "5678", True, 4), ], ) async def test_reconfigure_migrate_with_addon( @@ -3572,12 +3589,15 @@ async def test_reconfigure_migrate_with_addon( get_server_version: AsyncMock, restore_server_version_side_effect: Exception | None, final_unique_id: str, + keep_old_devices: bool, + device_entry_count: int, ) -> None: """Test migration flow with add-on.""" version_info = get_server_version.return_value entry = integration assert client.connect.call_count == 1 assert client.driver.controller.home_id == 3245146787 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, data={ @@ -3745,10 +3765,10 @@ async def test_reconfigure_migrate_with_addon( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert ("keep_old_devices" in entry.data) is keep_old_devices assert entry.unique_id == final_unique_id - assert len(device_registry.devices) == 2 + assert len(device_registry.devices) == device_entry_count controller_device_id_ext = ( f"{controller_device_id}-{controller_node.manufacturer_id}:" f"{controller_node.product_type}:{controller_node.product_id}" @@ -3780,9 +3800,10 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( """Test migration flow with driver ready timeout after nvm restore.""" entry = integration assert client.connect.call_count == 1 + assert client.driver.controller.home_id == 3245146787 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -3790,6 +3811,11 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -3811,6 +3837,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) @@ -3894,7 +3921,8 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert "keep_old_devices" in entry.data + assert entry.unique_id == "1234" async def test_reconfigure_migrate_backup_failure( diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 930f27e73f0..d9b3f392dd6 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -2070,12 +2070,8 @@ async def test_server_logging( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 2 - assert client.async_send_command.call_args_list[0][0][0] == { - "command": "controller.get_provisioning_entries", - } - assert client.async_send_command.call_args_list[1][0][0] == { - "command": "controller.get_provisioning_entry", - "dskOrNodeId": 1, + assert "driver.update_log_config" not in { + call[0][0]["command"] for call in client.async_send_command.call_args_list } assert not client.enable_server_logging.called assert not client.disable_server_logging.called diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d783e3deaba..d47fd771127 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -1,13 +1,14 @@ """Test the Z-Wave JS repairs module.""" from copy import deepcopy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.const import CONF_KEEP_OLD_DEVICES from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -276,8 +277,12 @@ async def test_migrate_unique_id( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + client: MagicMock, + multisensor_6: Node, ) -> None: """Test the migrate unique id flow.""" + node = multisensor_6 old_unique_id = "123456789" config_entry = MockConfigEntry( domain=DOMAIN, @@ -289,8 +294,27 @@ async def test_migrate_unique_id( ) config_entry.add_to_hass(hass) + # Remove the node from the current controller's known nodes. + client.driver.controller.nodes.pop(node.node_id) + + # Create a device entry for the node connected to the old controller. + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{old_unique_id}-{node.node_id}")}, + name="Node connected to old controller", + ) + assert device_entry.name == "Node connected to old controller" + await hass.config_entries.async_setup(config_entry.entry_id) + assert CONF_KEEP_OLD_DEVICES in config_entry.data + assert config_entry.data[CONF_KEEP_OLD_DEVICES] is True + stored_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(stored_devices) == 2 + assert device_entry.id in {device.id for device in stored_devices} + await async_process_repairs_platforms(hass) ws_client = await hass_ws_client(hass) http_client = await hass_client() @@ -317,6 +341,13 @@ async def test_migrate_unique_id( # Apply fix data = await process_repair_fix_flow(http_client, flow_id) + await hass.async_block_till_done() + + stored_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(stored_devices) == 1 + assert device_entry.id not in {device.id for device in stored_devices} assert data["type"] == "create_entry" assert config_entry.unique_id == "3245146787" From ee2cf961f6624786af7e4e6d25b9b02a72e5792e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:17:09 -0400 Subject: [PATCH 204/235] Add assumed optimistic functionality to lock platform (#149397) --- homeassistant/components/template/lock.py | 57 ++++++++++++----------- tests/components/template/test_lock.py | 49 +++++++++++++++++++ 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 848469b0ca4..e89f95734d1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -29,12 +29,13 @@ from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_PICTURE, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -54,18 +55,18 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -LOCK_YAML_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_CODE_FORMAT): cv.template, - vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_PICTURE): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +LOCK_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CODE_FORMAT): cv.template, + vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + } +) + +LOCK_YAML_SCHEMA = LOCK_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema ) PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( @@ -105,6 +106,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. @@ -112,12 +114,9 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Initialize the features.""" self._state: LockState | None = None - self._state_template = config.get(CONF_STATE) self._code_format_template = config.get(CONF_CODE_FORMAT) self._code_format: str | None = None self._code_format_template_error: TemplateError | None = None - self._optimistic = config.get(CONF_OPTIMISTIC) - self._attr_assumed_state = bool(self._optimistic) def _iterate_scripts( self, config: dict[str, Any] @@ -211,7 +210,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.LOCKED self.async_write_ha_state() @@ -229,7 +228,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.UNLOCKED self.async_write_ha_state() @@ -247,7 +246,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.OPEN self.async_write_ha_state() @@ -310,11 +309,13 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock): @callback def _async_setup_templates(self) -> None: """Set up templates.""" - if TYPE_CHECKING: - assert self._state_template is not None - self.add_template_attribute( - "_state", self._state_template, None, self._update_state - ) + if self._template is not None: + self.add_template_attribute( + "_state", + self._template, + None, + self._update_state, + ) if self._code_format_template: self.add_template_attribute( "_code_format_template", @@ -329,7 +330,6 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): """Lock entity based on trigger data.""" domain = LOCK_DOMAIN - extra_template_keys = (CONF_STATE,) def __init__( self, @@ -343,6 +343,9 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + if CONF_STATE in config: + self._to_render_simple.append(CONF_STATE) + if isinstance(config.get(CONF_CODE_FORMAT), template.Template): self._to_render_simple.append(CONF_CODE_FORMAT) self._parse_result.add(CONF_CODE_FORMAT) @@ -371,9 +374,9 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): updater(rendered) write_ha_state = True - if not self._optimistic: + if not self._attr_assumed_state: write_ha_state = True - elif self._optimistic and len(self._rendered) > 0: + elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template write_ha_state = True diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index cbee71824ae..457c5b7bf5c 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -1137,3 +1137,52 @@ async def test_emtpy_action_config(hass: HomeAssistant) -> None: state = hass.states.get("lock.test_template_lock") assert state.state == LockState.LOCKED + + +@pytest.mark.parametrize( + ("count", "lock_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "lock": [], + "unlock": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_lock") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED + + # Ensure Trigger template entities update. + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED From 1895db0ddd0bc205589d7ab3aa3d578840b9eecb Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:17:39 -0400 Subject: [PATCH 205/235] Add optimistic option to switch yaml (#149402) --- homeassistant/components/template/switch.py | 114 +++++++++----------- tests/components/template/test_switch.py | 60 ++++++++++- 2 files changed, 110 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index f5835f2d478..cc0fd4c7ad2 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -38,6 +38,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .entity import AbstractTemplateEntity from .helpers import ( async_setup_template_entry, async_setup_template_platform, @@ -46,6 +47,7 @@ from .helpers import ( from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -68,8 +70,8 @@ SWITCH_COMMON_SCHEMA = vol.Schema( ) SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend( - make_template_entity_common_modern_schema(DEFAULT_NAME).schema -) + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SWITCH_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), @@ -145,11 +147,38 @@ def async_create_preview_switch( ) -class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): +class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity): + """Representation of a template switch features.""" + + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Fire the on action.""" + if on_script := self._action_scripts.get(CONF_TURN_ON): + await self.async_run_script(on_script, context=self._context) + if self._attr_assumed_state: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Fire the off action.""" + if off_script := self._action_scripts.get(CONF_TURN_OFF): + await self.async_run_script(off_script, context=self._context) + if self._attr_assumed_state: + self._attr_is_on = False + self.async_write_ha_state() + + +class StateSwitchEntity(TemplateEntity, AbstractTemplateSwitch): """Representation of a Template switch.""" _attr_should_poll = False - _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -158,12 +187,12 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config, unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateSwitch.__init__(self, config) name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_STATE) # Scripts can be an empty list, therefore we need to check for None if (on_action := config.get(CONF_TURN_ON)) is not None: @@ -171,25 +200,22 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): if (off_action := config.get(CONF_TURN_OFF)) is not None: self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) - self._state: bool | None = False - self._attr_assumed_state = self._template is None - @callback def _update_state(self, result): super()._update_state(result) if isinstance(result, TemplateError): - self._state = None + self._attr_is_on = None return if isinstance(result, bool): - self._state = result + self._attr_is_on = result return if isinstance(result, str): - self._state = result.lower() in ("true", STATE_ON) + self._attr_is_on = result.lower() in ("true", STATE_ON) return - self._state = False + self._attr_is_on = False async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -197,7 +223,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): # restore state after startup await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._state = state.state == STATE_ON + self._attr_is_on = state.state == STATE_ON await super().async_added_to_hass() @callback @@ -205,37 +231,15 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Set up templates.""" if self._template is not None: self.add_template_attribute( - "_state", self._template, None, self._update_state + "_attr_is_on", self._template, None, self._update_state ) super()._async_setup_templates() - @property - def is_on(self) -> bool | None: - """Return true if device is on.""" - return self._state - async def async_turn_on(self, **kwargs: Any) -> None: - """Fire the on action.""" - if on_script := self._action_scripts.get(CONF_TURN_ON): - await self.async_run_script(on_script, context=self._context) - if self._template is None: - self._state = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Fire the off action.""" - if off_script := self._action_scripts.get(CONF_TURN_OFF): - await self.async_run_script(off_script, context=self._context) - if self._template is None: - self._state = False - self.async_write_ha_state() - - -class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): +class TriggerSwitchEntity(TriggerEntity, AbstractTemplateSwitch): """Switch entity based on trigger data.""" - _entity_id_format = ENTITY_ID_FORMAT domain = SWITCH_DOMAIN def __init__( @@ -245,17 +249,16 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): config: ConfigType, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateSwitch.__init__(self, config) name = self._rendered.get(CONF_NAME, DEFAULT_NAME) - self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) if off_action := config.get(CONF_TURN_OFF): self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) - self._attr_assumed_state = self._template is None - if not self._attr_assumed_state: + if CONF_STATE in config: self._to_render_simple.append(CONF_STATE) self._parse_result.add(CONF_STATE) @@ -281,28 +284,15 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): self.async_write_ha_state() return - if not self._attr_assumed_state: - raw = self._rendered.get(CONF_STATE) - self._attr_is_on = template.result_as_boolean(raw) + write_ha_state = False + if (state := self._rendered.get(CONF_STATE)) is not None: + self._attr_is_on = template.result_as_boolean(state) + write_ha_state = True - self.async_write_ha_state() - elif self._attr_assumed_state and len(self._rendered) > 0: + elif len(self._rendered) > 0: # In case name, icon, or friendly name have a template but # states does not - self.async_write_ha_state() + write_ha_state = True - async def async_turn_on(self, **kwargs: Any) -> None: - """Fire the on action.""" - if on_script := self._action_scripts.get(CONF_TURN_ON): - await self.async_run_script(on_script, context=self._context) - if self._template is None: - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Fire the off action.""" - if off_script := self._action_scripts.get(CONF_TURN_OFF): - await self.async_run_script(off_script, context=self._context) - if self._template is None: - self._attr_is_on = False + if write_ha_state: self.async_write_ha_state() diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 2e2fb5e8093..a32f1df4c76 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -34,8 +34,13 @@ TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "switch.test_state" TEST_EVENT_TRIGGER = { - "trigger": {"platform": "event", "event_type": "test_event"}, - "variables": {"type": "{{ trigger.event.data.type }}"}, + "triggers": [ + {"trigger": "event", "event_type": "test_event"}, + {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID]}, + ], + "variables": { + "type": "{{ trigger.event.data.type if trigger.event is defined else trigger.entity_id }}" + }, "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], } @@ -1211,3 +1216,54 @@ async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "switch_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('switch.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, + ], +) +@pytest.mark.usefixtures("setup_switch") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + switch.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF From b3862591ea217a1659f795ec934840ffe01ec929 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:18:37 -0400 Subject: [PATCH 206/235] Add optimism to vacuum platform (#149425) --- homeassistant/components/template/vacuum.py | 70 +++++++------ tests/components/template/test_vacuum.py | 108 ++++++++++++++++++++ 2 files changed, 149 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 5ff99020f0d..67f0f780388 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -44,6 +44,7 @@ from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, ) @@ -76,24 +77,26 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -VACUUM_YAML_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_BATTERY_LEVEL): cv.template, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, - vol.Optional(CONF_FAN_SPEED): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, - vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) +VACUUM_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_BATTERY_LEVEL): cv.template, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, + vol.Optional(CONF_FAN_SPEED): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, + vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + } ) +VACUUM_YAML_SCHEMA = VACUUM_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) + VACUUM_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( @@ -147,16 +150,15 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) self._battery_level_template = config.get(CONF_BATTERY_LEVEL) self._fan_speed_template = config.get(CONF_FAN_SPEED) - self._state = None self._battery_level = None self._attr_fan_speed = None @@ -185,17 +187,12 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): if (action_config := config.get(action_id)) is not None: yield (action_id, action_config, supported_feature) - @property - def activity(self) -> VacuumActivity | None: - """Return the status of the vacuum cleaner.""" - return self._state - def _handle_state(self, result: Any) -> None: # Validate state if result in _VALID_STATES: - self._state = result + self._attr_activity = result elif result == STATE_UNKNOWN: - self._state = None + self._attr_activity = None else: _LOGGER.error( "Received invalid vacuum state: %s for entity %s. Expected: %s", @@ -203,31 +200,46 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self.entity_id, ", ".join(_VALID_STATES), ) - self._state = None + self._attr_activity = None async def async_start(self) -> None: """Start or resume the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() await self.async_run_script( self._action_scripts[SERVICE_START], context=self._context ) async def async_pause(self) -> None: """Pause the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.PAUSED + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_PAUSE): await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.IDLE + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_STOP): await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.RETURNING + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_RETURN_TO_BASE): await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_CLEAN_SPOT): await self.async_run_script(script, context=self._context) @@ -274,7 +286,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): if isinstance(fan_speed, TemplateError): # This is legacy behavior self._attr_fan_speed = None - self._state = None + self._attr_activity = None return if fan_speed in self._attr_fan_speed_list: @@ -320,7 +332,7 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): """Set up templates.""" if self._template is not None: self.add_template_attribute( - "_state", self._template, None, self._update_state + "_attr_activity", self._template, None, self._update_state ) if self._fan_speed_template is not None: self.add_template_attribute( @@ -344,7 +356,7 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): super()._update_state(result) if isinstance(result, TemplateError): # This is legacy behavior - self._state = STATE_UNKNOWN + self._attr_activity = None if not self._availability_template: self._attr_available = True return diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index ae65823309a..540b4eccd3b 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1153,3 +1153,111 @@ async def test_empty_action_config( assert state.attributes["supported_features"] == ( VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features ) + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + {"name": TEST_OBJECT_ID, "start": [], **TEMPLATE_VACUUM_ACTIONS}, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("service", "expected"), + [ + (vacuum.SERVICE_START, VacuumActivity.CLEANING), + (vacuum.SERVICE_PAUSE, VacuumActivity.PAUSED), + (vacuum.SERVICE_STOP, VacuumActivity.IDLE), + (vacuum.SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (vacuum.SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_assumed_optimistic( + hass: HomeAssistant, + service: str, + expected: VacuumActivity, + calls: list[ServiceCall], +) -> None: + """Test assumed optimistic.""" + + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('sensor.test_state') }}", + "start": [], + **TEMPLATE_VACUUM_ACTIONS, + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("service", "expected"), + [ + (vacuum.SERVICE_START, VacuumActivity.CLEANING), + (vacuum.SERVICE_PAUSE, VacuumActivity.PAUSED), + (vacuum.SERVICE_STOP, VacuumActivity.IDLE), + (vacuum.SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (vacuum.SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_optimistic_option( + hass: HomeAssistant, + service: str, + expected: VacuumActivity, + calls: list[ServiceCall], +) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.RETURNING) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == VacuumActivity.DOCKED From 978ee3870c0631fd17e089de05489c8e20da966a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:18:57 +0200 Subject: [PATCH 207/235] Add notify platform to PlayStation Network integration (#149557) --- .../playstation_network/__init__.py | 9 +- .../playstation_network/binary_sensor.py | 7 +- .../playstation_network/coordinator.py | 20 +++ .../playstation_network/diagnostics.py | 10 +- .../components/playstation_network/entity.py | 8 +- .../components/playstation_network/icons.json | 5 + .../components/playstation_network/image.py | 1 + .../components/playstation_network/notify.py | 126 +++++++++++++++++ .../components/playstation_network/sensor.py | 7 +- .../playstation_network/strings.json | 14 ++ .../playstation_network/conftest.py | 11 ++ .../snapshots/test_diagnostics.ambr | 12 +- .../snapshots/test_notify.ambr | 50 +++++++ .../playstation_network/test_notify.py | 127 ++++++++++++++++++ 14 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/playstation_network/notify.py create mode 100644 tests/components/playstation_network/snapshots/test_notify.ambr create mode 100644 tests/components/playstation_network/test_notify.py diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index be0eae961e0..bfa9de5d5cb 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_NPSSO from .coordinator import ( PlaystationNetworkConfigEntry, + PlaystationNetworkGroupsUpdateCoordinator, PlaystationNetworkRuntimeData, PlaystationNetworkTrophyTitlesCoordinator, PlaystationNetworkUserDataCoordinator, @@ -18,6 +19,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.IMAGE, Platform.MEDIA_PLAYER, + Platform.NOTIFY, Platform.SENSOR, ] @@ -34,7 +36,12 @@ async def async_setup_entry( trophy_titles = PlaystationNetworkTrophyTitlesCoordinator(hass, psn, entry) - entry.runtime_data = PlaystationNetworkRuntimeData(coordinator, trophy_titles) + groups = PlaystationNetworkGroupsUpdateCoordinator(hass, psn, entry) + await groups.async_config_entry_first_refresh() + + entry.runtime_data = PlaystationNetworkRuntimeData( + coordinator, trophy_titles, groups + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py index 453cfb37347..89a752eff0e 100644 --- a/homeassistant/components/playstation_network/binary_sensor.py +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -13,7 +13,11 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) from .entity import PlaystationNetworkServiceEntity PARALLEL_UPDATES = 0 @@ -63,6 +67,7 @@ class PlaystationNetworkBinarySensorEntity( """Representation of a PlayStation Network binary sensor entity.""" entity_description: PlaystationNetworkBinarySensorEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator @property def is_on(self) -> bool: diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index fa00ac2c8ec..19153d1bb01 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -12,6 +12,7 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPClientError, PSNAWPServerError, ) +from psnawp_api.models.group.group_datatypes import GroupDetails from psnawp_api.models.trophies import TrophyTitle from homeassistant.config_entries import ConfigEntry @@ -33,6 +34,7 @@ class PlaystationNetworkRuntimeData: user_data: PlaystationNetworkUserDataCoordinator trophy_titles: PlaystationNetworkTrophyTitlesCoordinator + groups: PlaystationNetworkGroupsUpdateCoordinator class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -120,3 +122,21 @@ class PlaystationNetworkTrophyTitlesCoordinator( ) await self.config_entry.runtime_data.user_data.async_request_refresh() return self.psn.trophy_titles + + +class PlaystationNetworkGroupsUpdateCoordinator( + PlayStationNetworkBaseCoordinator[dict[str, GroupDetails]] +): + """Groups data update coordinator for PSN.""" + + _update_interval = timedelta(hours=3) + + async def update_data(self) -> dict[str, GroupDetails]: + """Update groups data.""" + return await self.hass.async_add_executor_job( + lambda: { + group_info.group_id: group_info.get_group_information() + for group_info in self.psn.client.get_groups() + if not group_info.group_id.startswith("~") + } + ) diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py index 7b5c762db12..710760a015c 100644 --- a/homeassistant/components/playstation_network/diagnostics.py +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -20,6 +20,11 @@ TO_REDACT = { "onlineId", "url", "username", + "onlineId", + "accountId", + "members", + "body", + "shareable_profile_link", } @@ -28,11 +33,12 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data.user_data - + groups = entry.runtime_data.groups return { "data": async_redact_data( _serialize_platform_types(asdict(coordinator.data)), TO_REDACT - ) + ), + "groups": async_redact_data(groups.data, TO_REDACT), } diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index 660c77dc30f..ad7c52bdb39 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -7,11 +7,11 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PlaystationNetworkUserDataCoordinator +from .coordinator import PlayStationNetworkBaseCoordinator class PlaystationNetworkServiceEntity( - CoordinatorEntity[PlaystationNetworkUserDataCoordinator] + CoordinatorEntity[PlayStationNetworkBaseCoordinator] ): """Common entity class for PlayStationNetwork Service entities.""" @@ -19,7 +19,7 @@ class PlaystationNetworkServiceEntity( def __init__( self, - coordinator: PlaystationNetworkUserDataCoordinator, + coordinator: PlayStationNetworkBaseCoordinator, entity_description: EntityDescription, ) -> None: """Initialize PlayStation Network Service Entity.""" @@ -32,7 +32,7 @@ class PlaystationNetworkServiceEntity( ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, - name=coordinator.data.username, + name=coordinator.psn.user.online_id, entry_type=DeviceEntryType.SERVICE, manufacturer="Sony Interactive Entertainment", ) diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2ea09823ca4..af2236bd126 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -51,6 +51,11 @@ "avatar": { "default": "mdi:account-circle" } + }, + "notify": { + "group_message": { + "default": "mdi:forum" + } } } } diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py index 8f9d19e3a55..b0195002c66 100644 --- a/homeassistant/components/playstation_network/image.py +++ b/homeassistant/components/playstation_network/image.py @@ -79,6 +79,7 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity """An image entity.""" entity_description: PlaystationNetworkImageEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator def __init__( self, diff --git a/homeassistant/components/playstation_network/notify.py b/homeassistant/components/playstation_network/notify.py new file mode 100644 index 00000000000..872ad98a594 --- /dev/null +++ b/homeassistant/components/playstation_network/notify.py @@ -0,0 +1,126 @@ +"""Notify platform for PlayStation Network.""" + +from __future__ import annotations + +from enum import StrEnum + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) + +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + NotifyEntity, + NotifyEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkGroupsUpdateCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 20 + + +class PlaystationNetworkNotify(StrEnum): + """PlayStation Network sensors.""" + + GROUP_MESSAGE = "group_message" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notify entity platform.""" + + coordinator = config_entry.runtime_data.groups + groups_added: set[str] = set() + entity_registry = er.async_get(hass) + + @callback + def add_entities() -> None: + nonlocal groups_added + + new_groups = set(coordinator.data.keys()) - groups_added + if new_groups: + async_add_entities( + PlaystationNetworkNotifyEntity(coordinator, group_id) + for group_id in new_groups + ) + groups_added |= new_groups + + deleted_groups = groups_added - set(coordinator.data.keys()) + for group_id in deleted_groups: + if entity_id := entity_registry.async_get_entity_id( + NOTIFY_DOMAIN, + DOMAIN, + f"{coordinator.config_entry.unique_id}_{group_id}", + ): + entity_registry.async_remove(entity_id) + + coordinator.async_add_listener(add_entities) + add_entities() + + +class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEntity): + """Representation of a PlayStation Network notify entity.""" + + coordinator: PlaystationNetworkGroupsUpdateCoordinator + + def __init__( + self, + coordinator: PlaystationNetworkGroupsUpdateCoordinator, + group_id: str, + ) -> None: + """Initialize a notification entity.""" + self.group = coordinator.psn.psn.group(group_id=group_id) + group_details = coordinator.data[group_id] + self.entity_description = NotifyEntityDescription( + key=group_id, + translation_key=PlaystationNetworkNotify.GROUP_MESSAGE, + translation_placeholders={ + "group_name": group_details["groupName"]["value"] + or ", ".join( + member["onlineId"] + for member in group_details["members"] + if member["accountId"] != coordinator.psn.user.account_id + ) + }, + ) + + super().__init__(coordinator, self.entity_description) + + def send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + + try: + self.group.send_message(message) + except PSNAWPNotFoundError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="group_invalid", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except PSNAWPForbiddenError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_forbidden", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except (PSNAWPServerError, PSNAWPClientError) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders=dict(self.translation_placeholders), + ) from e diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index b17b4c04ab7..63cca074c3e 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -18,7 +18,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) from .entity import PlaystationNetworkServiceEntity PARALLEL_UPDATES = 0 @@ -145,6 +149,7 @@ class PlaystationNetworkSensorEntity( """Representation of a PlayStation Network sensor entity.""" entity_description: PlaystationNetworkSensorEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator @property def native_value(self) -> StateType | datetime: diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index aaefdf51506..4fefc508ea2 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -50,6 +50,15 @@ }, "update_failed": { "message": "Data retrieval failed when trying to access the PlayStation Network." + }, + "group_invalid": { + "message": "Failed to send message to group {group_name}. The group is invalid or does not exist." + }, + "send_message_forbidden": { + "message": "Failed to send message to group {group_name}. You are not allowed to send messages to this group." + }, + "send_message_failed": { + "message": "Failed to send message to group {group_name}. Try again later." } }, "entity": { @@ -104,6 +113,11 @@ "avatar": { "name": "Avatar" } + }, + "notify": { + "group_message": { + "name": "Group: {group_name}" + } } } } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 77ec2377932..8480d7ecf5d 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch +from psnawp_api.models.group.group import Group from psnawp_api.models.trophies import ( PlatformType, TrophySet, @@ -159,6 +160,16 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: client.me.return_value.get_shareable_profile_link.return_value = { "shareImageUrl": "https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493" } + group = MagicMock(spec=Group, group_id="test-groupid") + + group.get_group_information.return_value = { + "groupName": {"value": ""}, + "members": [ + {"onlineId": "PublicUniversalFriend", "accountId": "fren-psn-id"}, + {"onlineId": "testuser", "accountId": PSN_ID}, + ], + } + client.me.return_value.get_groups.return_value = [group] yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index 0b7aa63fc03..894fa2d9084 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -71,9 +71,7 @@ 'PS5', 'PSVITA', ]), - 'shareable_profile_link': dict({ - 'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493', - }), + 'shareable_profile_link': '**REDACTED**', 'trophy_summary': dict({ 'account_id': '**REDACTED**', 'earned_trophies': dict({ @@ -88,5 +86,13 @@ }), 'username': '**REDACTED**', }), + 'groups': dict({ + 'test-groupid': dict({ + 'groupName': dict({ + 'value': '', + }), + 'members': '**REDACTED**', + }), + }), }) # --- diff --git a/tests/components/playstation_network/snapshots/test_notify.ambr b/tests/components/playstation_network/snapshots/test_notify.ambr new file mode 100644 index 00000000000..60525925787 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_notify.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group: PublicUniversalFriend', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_test-groupid', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Group: PublicUniversalFriend', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/playstation_network/test_notify.py b/tests/components/playstation_network/test_notify.py new file mode 100644 index 00000000000..ebaac37a09f --- /dev/null +++ b/tests/components/playstation_network/test_notify.py @@ -0,0 +1,127 @@ +"""Tests for the PlayStation Network notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +from freezegun.api import freeze_time +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@freeze_time("2025-07-28T00:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test send message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == "2025-07-28T00:00:00+00:00" + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") + + +@pytest.mark.parametrize( + "exception", + [PSNAWPClientError, PSNAWPForbiddenError, PSNAWPNotFoundError, PSNAWPServerError], +) +async def test_send_message_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test send message exceptions.""" + + mock_psnawpapi.group.return_value.send_message.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == STATE_UNKNOWN + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") From e8b8d310276e8a1a4068900e8790ee4580dde0e6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 28 Jul 2025 16:31:13 +0200 Subject: [PATCH 208/235] Make actions labels consistent for Template alarm control panel (#149574) --- homeassistant/components/template/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index e178b383a78..be91b27e485 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -17,13 +17,13 @@ "device_id": "[%key:common::config_flow::data::device%]", "value_template": "[%key:component::template::common::state%]", "name": "[%key:common::config_flow::data::name%]", - "disarm": "Disarm action", - "arm_away": "Arm away action", - "arm_custom_bypass": "Arm custom bypass action", - "arm_home": "Arm home action", - "arm_night": "Arm night action", - "arm_vacation": "Arm vacation action", - "trigger": "Trigger action", + "disarm": "Actions on disarm", + "arm_away": "Actions on arm away", + "arm_custom_bypass": "Actions on arm custom bypass", + "arm_home": "Actions on arm home", + "arm_night": "Actions on arm night", + "arm_vacation": "Actions on arm vacation", + "trigger": "Actions on trigger", "code_arm_required": "Code arm required", "code_format": "[%key:component::template::common::code_format%]" }, From 5ef17c8588b1e8c69218c2cfd7e3cc7e585bcf82 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 28 Jul 2025 16:32:56 +0200 Subject: [PATCH 209/235] Bump the required version of ruff to 0.12.1 (#149571) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b75b80f47dd..d15a93fd8bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -647,7 +647,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.11.0" +required-version = ">=0.12.1" [tool.ruff.lint] select = [ From d3f18c1678983cafb9776aff3577d5bfd0b2013e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 28 Jul 2025 15:35:38 +0100 Subject: [PATCH 210/235] Add quality scale to ring manifest (#149406) --- homeassistant/components/ring/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 86758b26794..e7436e4d12d 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -29,5 +29,6 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], + "quality_scale": "bronze", "requirements": ["ring-doorbell==0.9.13"] } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index def20d9d4cc..1d6db8e1f7a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1890,7 +1890,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "rfxtrx", "rhasspy", "ridwell", - "ring", "ripple", "risco", "rituals_perfume_genie", From 49bd15718cfffc6fab3ca4d4f42d3adf6857986d Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:58:46 -0400 Subject: [PATCH 211/235] Add optimistic option to fan yaml (#149390) --- homeassistant/components/template/fan.py | 55 ++++++++++++------------ tests/components/template/test_fan.py | 48 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 2d0d06f86a1..381d58a8a9c 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -43,6 +43,7 @@ from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -81,24 +82,26 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Fan" -FAN_YAML_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_DIRECTION): cv.template, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OSCILLATING): cv.template, - vol.Optional(CONF_PERCENTAGE): cv.template, - vol.Optional(CONF_PRESET_MODE): cv.template, - vol.Optional(CONF_PRESET_MODES): cv.ensure_list, - vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), - vol.Optional(CONF_STATE): cv.template, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +FAN_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DIRECTION): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OSCILLATING): cv.template, + vol.Optional(CONF_PERCENTAGE): cv.template, + vol.Optional(CONF_PRESET_MODE): cv.template, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), + vol.Optional(CONF_STATE): cv.template, + } +) + +FAN_YAML_SCHEMA = FAN_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema ) FAN_LEGACY_YAML_SCHEMA = vol.All( @@ -154,13 +157,12 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - - self._template = config.get(CONF_STATE) self._percentage_template = config.get(CONF_PERCENTAGE) self._preset_mode_template = config.get(CONF_PRESET_MODE) self._oscillating_template = config.get(CONF_OSCILLATING) @@ -177,7 +179,6 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): # List of valid preset modes self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) - self._attr_assumed_state = self._template is None self._attr_supported_features |= ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON @@ -339,7 +340,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): if percentage is not None: await self.async_set_percentage(percentage) - if self._template is None: + if self._attr_assumed_state: self._state = True self.async_write_ha_state() @@ -349,7 +350,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): self._action_scripts[CONF_OFF_ACTION], context=self._context ) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() @@ -364,10 +365,10 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): context=self._context, ) - if self._template is None: + if self._attr_assumed_state: self._state = percentage != 0 - if self._template is None or self._percentage_template is None: + if self._attr_assumed_state or self._percentage_template is None: self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -381,10 +382,10 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): context=self._context, ) - if self._template is None: + if self._attr_assumed_state: self._state = True - if self._template is None or self._preset_mode_template is None: + if self._attr_assumed_state or self._preset_mode_template is None: self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 708ad6bdecd..c0af18166df 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1833,3 +1833,51 @@ async def test_nested_unique_id( entry = entity_registry.async_get("fan.test_b") assert entry assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_sensor', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_fan") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(_STATE_TEST_SENSOR, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF From d823b574c0f57040c1b3bc9f5eae473d279ea811 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:59:57 -0400 Subject: [PATCH 212/235] Add optimistic option to light yaml (#149395) --- homeassistant/components/template/light.py | 19 +++++--- tests/components/template/test_light.py | 51 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 07591ce9653..19eecaa7006 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -53,6 +53,7 @@ from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -121,7 +122,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Light" -LIGHT_YAML_SCHEMA = vol.Schema( +LIGHT_COMMON_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, @@ -132,6 +133,8 @@ LIGHT_YAML_SCHEMA = vol.Schema( vol.Optional(CONF_LEVEL): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.template, vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB): cv.template, vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, @@ -142,9 +145,11 @@ LIGHT_YAML_SCHEMA = vol.Schema( vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE): cv.template, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, } +) + +LIGHT_YAML_SCHEMA = LIGHT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LIGHT_LEGACY_YAML_SCHEMA = vol.All( @@ -215,6 +220,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. @@ -224,7 +230,6 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Initialize the features.""" # Template attributes - self._template = config.get(CONF_STATE) self._level_template = config.get(CONF_LEVEL) self._temperature_template = config.get(CONF_TEMPERATURE) self._hs_template = config.get(CONF_HS) @@ -349,7 +354,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): Returns True if any attribute was updated. """ optimistic_set = False - if self._template is None: + if self._attr_assumed_state: self._state = True optimistic_set = True @@ -1066,7 +1071,7 @@ class StateLightEntity(TemplateEntity, AbstractTemplateLight): ) else: await self.async_run_script(off_script, context=self._context) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() @@ -1205,6 +1210,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): ) else: await self.async_run_script(off_script, context=self._context) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index bfffd0911a9..b42eba0665d 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -37,6 +37,9 @@ from tests.common import assert_setup_component # Represent for light's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" +TEST_OBJECT_ID = "test_light" +TEST_ENTITY_ID = f"light.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "light.test_state" OPTIMISTIC_ON_OFF_LIGHT_CONFIG = { "turn_on": { @@ -2740,3 +2743,51 @@ async def test_effect_with_empty_action( """Test empty set_effect action.""" state = hass.states.get("light.test_template_light") assert state.attributes["supported_features"] == LightEntityFeature.EFFECT + + +@pytest.mark.parametrize( + ("count", "light_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('light.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_light") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + light.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF From 8f795f021c0f397d66cf38298cc8e46bcc3c2bce Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 28 Jul 2025 17:19:43 +0200 Subject: [PATCH 213/235] Bump Plugwise to v1.7.8 preventing rogue KeyError (#149000) --- homeassistant/components/plugwise/climate.py | 2 +- homeassistant/components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/select.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/fixtures/m_adam_jip/data.json | 8 ++++++++ tests/components/plugwise/test_climate.py | 7 +++++-- 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 71846a04bbd..22f204444d5 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -165,7 +165,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if "regulation_modes" in self._gateway_data: hvac_modes.append(HVACMode.OFF) - if "available_schedules" in self.device: + if self.device.get("available_schedules"): hvac_modes.append(HVACMode.AUTO) if self.coordinator.api.cooling_present: diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 09cec98292a..69b456ca8d8 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.7"], + "requirements": ["plugwise==1.7.8"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 6ca1d4ce7a2..6fc8f1615a7 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -70,7 +70,7 @@ async def async_setup_entry( PlugwiseSelectEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in SELECT_TYPES - if description.options_key in coordinator.data[device_id] + if coordinator.data[device_id].get(description.options_key) ) _add_entities() @@ -98,7 +98,7 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): self._location = location @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self.device[self.entity_description.key] diff --git a/requirements_all.txt b/requirements_all.txt index b2e14e4241c..41947853270 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1693,7 +1693,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.7 +plugwise==1.7.8 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21eab297f03..c807c93a6c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1431,7 +1431,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.7 +plugwise==1.7.8 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/m_adam_jip/data.json b/tests/components/plugwise/fixtures/m_adam_jip/data.json index 8de57910f66..50b9a8109ee 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/data.json @@ -1,11 +1,13 @@ { "06aecb3d00354375924f50c47af36bd2": { "active_preset": "no_frost", + "available_schedules": [], "climate_mode": "off", "dev_class": "climate", "model": "ThermoZone", "name": "Slaapkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 24.2 }, @@ -23,12 +25,14 @@ }, "13228dab8ce04617af318a2888b3c548": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Woonkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 27.4 }, @@ -236,12 +240,14 @@ }, "d27aede973b54be484f6842d1b2802ad": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Kinderkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 30.0 }, @@ -283,12 +289,14 @@ }, "d58fec52899f4f1c92e4f8fad6d8c48c": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Logeerkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 30.0 }, diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 3787cbf7150..b8554f9a5cc 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -433,13 +433,16 @@ async def test_anna_climate_entity_climate_changes( "c784ee9fdab44e1395b8dee7d7a497d5", HVACMode.OFF ) + # Mock user deleting last schedule from app or browser data = mock_smile_anna.async_update.return_value - data["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") + data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = [] + data["3cb70739631c4d17a86b8b12e8a5161b"]["select_schedule"] = None + data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat_cool" with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("climate.anna") - assert state.state == HVACMode.HEAT + assert state.state == HVACMode.HEAT_COOL assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] From 483d814a8f227e27b935f70f0e8ee587213df183 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:24:15 +0200 Subject: [PATCH 214/235] Add new Volvo integration (#142994) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/volvo/__init__.py | 97 + homeassistant/components/volvo/api.py | 38 + .../volvo/application_credentials.py | 37 + homeassistant/components/volvo/config_flow.py | 239 + homeassistant/components/volvo/const.py | 14 + homeassistant/components/volvo/coordinator.py | 255 ++ homeassistant/components/volvo/entity.py | 90 + homeassistant/components/volvo/icons.json | 81 + homeassistant/components/volvo/manifest.json | 13 + .../components/volvo/quality_scale.yaml | 82 + homeassistant/components/volvo/sensor.py | 388 ++ homeassistant/components/volvo/strings.json | 178 + .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/volvo/__init__.py | 52 + tests/components/volvo/conftest.py | 185 + tests/components/volvo/const.py | 19 + .../volvo/fixtures/availability.json | 6 + tests/components/volvo/fixtures/brakes.json | 6 + tests/components/volvo/fixtures/commands.json | 36 + .../volvo/fixtures/diagnostics.json | 25 + tests/components/volvo/fixtures/doors.json | 34 + .../volvo/fixtures/energy_capabilities.json | 33 + .../volvo/fixtures/energy_state.json | 42 + .../volvo/fixtures/engine_status.json | 6 + .../volvo/fixtures/engine_warnings.json | 10 + .../ex30_2024/energy_capabilities.json | 33 + .../fixtures/ex30_2024/energy_state.json | 57 + .../volvo/fixtures/ex30_2024/statistics.json | 32 + .../volvo/fixtures/ex30_2024/vehicle.json | 17 + .../volvo/fixtures/fuel_status.json | 12 + tests/components/volvo/fixtures/location.json | 11 + tests/components/volvo/fixtures/odometer.json | 7 + .../volvo/fixtures/recharge_status.json | 25 + .../fixtures/s90_diesel_2018/diagnostics.json | 25 + .../fixtures/s90_diesel_2018/statistics.json | 32 + .../fixtures/s90_diesel_2018/vehicle.json | 16 + tests/components/volvo/fixtures/tyres.json | 18 + tests/components/volvo/fixtures/warnings.json | 94 + tests/components/volvo/fixtures/windows.json | 22 + .../energy_capabilities.json | 33 + .../xc40_electric_2024/energy_state.json | 58 + .../xc40_electric_2024/statistics.json | 32 + .../fixtures/xc40_electric_2024/vehicle.json | 17 + .../fixtures/xc90_petrol_2019/commands.json | 44 + .../fixtures/xc90_petrol_2019/statistics.json | 32 + .../fixtures/xc90_petrol_2019/vehicle.json | 16 + .../volvo/snapshots/test_sensor.ambr | 3833 +++++++++++++++++ tests/components/volvo/test_config_flow.py | 303 ++ tests/components/volvo/test_coordinator.py | 151 + tests/components/volvo/test_init.py | 125 + tests/components/volvo/test_sensor.py | 32 + 58 files changed, 7070 insertions(+) create mode 100644 homeassistant/components/volvo/__init__.py create mode 100644 homeassistant/components/volvo/api.py create mode 100644 homeassistant/components/volvo/application_credentials.py create mode 100644 homeassistant/components/volvo/config_flow.py create mode 100644 homeassistant/components/volvo/const.py create mode 100644 homeassistant/components/volvo/coordinator.py create mode 100644 homeassistant/components/volvo/entity.py create mode 100644 homeassistant/components/volvo/icons.json create mode 100644 homeassistant/components/volvo/manifest.json create mode 100644 homeassistant/components/volvo/quality_scale.yaml create mode 100644 homeassistant/components/volvo/sensor.py create mode 100644 homeassistant/components/volvo/strings.json create mode 100644 tests/components/volvo/__init__.py create mode 100644 tests/components/volvo/conftest.py create mode 100644 tests/components/volvo/const.py create mode 100644 tests/components/volvo/fixtures/availability.json create mode 100644 tests/components/volvo/fixtures/brakes.json create mode 100644 tests/components/volvo/fixtures/commands.json create mode 100644 tests/components/volvo/fixtures/diagnostics.json create mode 100644 tests/components/volvo/fixtures/doors.json create mode 100644 tests/components/volvo/fixtures/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/energy_state.json create mode 100644 tests/components/volvo/fixtures/engine_status.json create mode 100644 tests/components/volvo/fixtures/engine_warnings.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/energy_state.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/statistics.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/vehicle.json create mode 100644 tests/components/volvo/fixtures/fuel_status.json create mode 100644 tests/components/volvo/fixtures/location.json create mode 100644 tests/components/volvo/fixtures/odometer.json create mode 100644 tests/components/volvo/fixtures/recharge_status.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/statistics.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json create mode 100644 tests/components/volvo/fixtures/tyres.json create mode 100644 tests/components/volvo/fixtures/warnings.json create mode 100644 tests/components/volvo/fixtures/windows.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/statistics.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/commands.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json create mode 100644 tests/components/volvo/snapshots/test_sensor.ambr create mode 100644 tests/components/volvo/test_config_flow.py create mode 100644 tests/components/volvo/test_coordinator.py create mode 100644 tests/components/volvo/test_init.py create mode 100644 tests/components/volvo/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 3f87bfa18e8..c6e27a011f1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -547,6 +547,7 @@ homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* +homeassistant.components.volvo.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/CODEOWNERS b/CODEOWNERS index f4f1d3b7a92..4e7c1b9175a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1706,6 +1706,8 @@ build.json @home-assistant/supervisor /tests/components/voip/ @balloob @synesthesiam @jaminh /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund +/homeassistant/components/volvo/ @thomasddn +/tests/components/volvo/ @thomasddn /homeassistant/components/volvooncall/ @molobrakos /tests/components/volvooncall/ @molobrakos /homeassistant/components/vulcan/ @Antoni-Czaplicki diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py new file mode 100644 index 00000000000..c6632185f0a --- /dev/null +++ b/homeassistant/components/volvo/__init__.py @@ -0,0 +1,97 @@ +"""The Volvo integration.""" + +from __future__ import annotations + +import asyncio + +from aiohttp import ClientResponseError +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoAuthException, VolvoCarsVehicle + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import VolvoAuth +from .const import CONF_VIN, DOMAIN, PLATFORMS +from .coordinator import ( + VolvoConfigEntry, + VolvoMediumIntervalCoordinator, + VolvoSlowIntervalCoordinator, + VolvoVerySlowIntervalCoordinator, +) + + +async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Set up Volvo from a config entry.""" + + api = await _async_auth_and_create_api(hass, entry) + vehicle = await _async_load_vehicle(api) + + # Order is important! Faster intervals must come first. + coordinators = ( + VolvoMediumIntervalCoordinator(hass, entry, api, vehicle), + VolvoSlowIntervalCoordinator(hass, entry, api, vehicle), + VolvoVerySlowIntervalCoordinator(hass, entry, api, vehicle), + ) + + await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) + + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _async_auth_and_create_api( + hass: HomeAssistant, entry: VolvoConfigEntry +) -> VolvoCarsApi: + implementation = await async_get_config_entry_implementation(hass, entry) + oauth_session = OAuth2Session(hass, entry, implementation) + web_session = async_get_clientsession(hass) + auth = VolvoAuth(web_session, oauth_session) + + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if err.status in (400, 401): + raise ConfigEntryAuthFailed from err + + raise ConfigEntryNotReady from err + + return VolvoCarsApi( + web_session, + auth, + entry.data[CONF_API_KEY], + entry.data[CONF_VIN], + ) + + +async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle: + try: + vehicle = await api.async_get_vehicle_details() + except VolvoAuthException as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": ex.message}, + ) from ex + + if vehicle is None: + raise ConfigEntryError(translation_domain=DOMAIN, translation_key="no_vehicle") + + return vehicle diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py new file mode 100644 index 00000000000..e2c1070f1ea --- /dev/null +++ b/homeassistant/components/volvo/api.py @@ -0,0 +1,38 @@ +"""API for Volvo bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from volvocarsapi.auth import AccessTokenManager + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + + +class VolvoAuth(AccessTokenManager): + """Provide Volvo authentication tied to an OAuth2 based config entry.""" + + def __init__(self, websession: ClientSession, oauth_session: OAuth2Session) -> None: + """Initialize Volvo auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token["access_token"]) + + +class ConfigFlowVolvoAuth(AccessTokenManager): + """Provide Volvo authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__(self, websession: ClientSession, token: str) -> None: + """Initialize ConfigFlowVolvoAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Volvo API.""" + return self._token diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py new file mode 100644 index 00000000000..18dae40f8ee --- /dev/null +++ b/homeassistant/components/volvo/application_credentials.py @@ -0,0 +1,37 @@ +"""Application credentials platform for the Volvo integration.""" + +from __future__ import annotations + +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL +from volvocarsapi.scopes import DEFAULT_SCOPES + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2ImplementationWithPkce, +) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> VolvoOAuth2Implementation: + """Return auth implementation for a custom auth implementation.""" + return VolvoOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + AUTHORIZE_URL, + TOKEN_URL, + credential.client_secret, + ) + + +class VolvoOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """Volvo oauth2 implementation.""" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": " ".join(DEFAULT_SCOPES), + } diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py new file mode 100644 index 00000000000..05d19fd1d26 --- /dev/null +++ b/homeassistant/components/volvo/config_flow.py @@ -0,0 +1,239 @@ +"""Config flow for Volvo.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .api import ConfigFlowVolvoAuth +from .const import CONF_VIN, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +def _create_volvo_cars_api( + hass: HomeAssistant, access_token: str, api_key: str +) -> VolvoCarsApi: + web_session = aiohttp_client.async_get_clientsession(hass) + auth = ConfigFlowVolvoAuth(web_session, access_token) + return VolvoCarsApi(web_session, auth, api_key) + + +class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Volvo OAuth2 authentication.""" + + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Initialize Volvo config flow.""" + super().__init__() + + self._vehicles: list[VolvoCarsVehicle] = [] + self._config_data: dict = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow.""" + self._config_data |= data + return await self.async_step_api_key() + + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_api_key() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) + return await self.async_step_user() + + async def async_step_api_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the API key step.""" + errors: dict[str, str] = {} + + if user_input is not None: + api = _create_volvo_cars_api( + self.hass, + self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], + user_input[CONF_API_KEY], + ) + + # Try to load all vehicles on the account. If it succeeds + # it means that the given API key is correct. The vehicle info + # is used in the VIN step. + try: + await self._async_load_vehicles(api) + except VolvoApiException: + _LOGGER.exception("Unable to retrieve vehicles") + errors["base"] = "cannot_load_vehicles" + + if not errors: + self._config_data |= user_input + return await self.async_step_vin() + + if user_input is None: + if self.source == SOURCE_REAUTH: + user_input = self._config_data = dict(self._get_reauth_entry().data) + api = _create_volvo_cars_api( + self.hass, + self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], + self._config_data[CONF_API_KEY], + ) + + # Test if the configured API key is still valid. If not, show this + # form. If it is, skip this step and go directly to the next step. + try: + await self._async_load_vehicles(api) + return await self.async_step_vin() + except VolvoApiException: + pass + + elif self.source == SOURCE_RECONFIGURE: + user_input = self._config_data = dict( + self._get_reconfigure_entry().data + ) + else: + user_input = {} + + schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, autocomplete="password" + ) + ), + } + ), + { + CONF_API_KEY: user_input.get(CONF_API_KEY, ""), + }, + ) + + return self.async_show_form( + step_id="api_key", + data_schema=schema, + errors=errors, + description_placeholders={ + "volvo_dev_portal": "https://developer.volvocars.com/account/#your-api-applications" + }, + ) + + async def async_step_vin( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the VIN step.""" + errors: dict[str, str] = {} + + if len(self._vehicles) == 1: + # If there is only one VIN, take that as value and + # immediately create the entry. No need to show + # the VIN step. + self._config_data[CONF_VIN] = self._vehicles[0].vin + return await self._async_create_or_update() + + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + # Don't let users change the VIN. The entry should be + # recreated if they want to change the VIN. + return await self._async_create_or_update() + + if user_input is not None: + self._config_data |= user_input + return await self._async_create_or_update() + + if len(self._vehicles) == 0: + errors[CONF_VIN] = "no_vehicles" + + schema = vol.Schema( + { + vol.Required(CONF_VIN): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=v.vin, + label=f"{v.description.model} ({v.vin})", + ) + for v in self._vehicles + ], + multiple=False, + ) + ), + }, + ) + + return self.async_show_form(step_id="vin", data_schema=schema, errors=errors) + + async def _async_create_or_update(self) -> ConfigFlowResult: + vin = self._config_data[CONF_VIN] + await self.async_set_unique_id(vin) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=self._config_data, + ) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self._config_data, + reload_even_if_entry_is_unchanged=False, + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {vin}", + data=self._config_data, + ) + + async def _async_load_vehicles(self, api: VolvoCarsApi) -> None: + self._vehicles = [] + vins = await api.async_get_vehicles() + + for vin in vins: + vehicle = await api.async_get_vehicle_details(vin) + + if vehicle: + self._vehicles.append(vehicle) diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py new file mode 100644 index 00000000000..675fc69945e --- /dev/null +++ b/homeassistant/components/volvo/const.py @@ -0,0 +1,14 @@ +"""Constants for the Volvo integration.""" + +from homeassistant.const import Platform + +DOMAIN = "volvo" +PLATFORMS: list[Platform] = [Platform.SENSOR] + +ATTR_API_TIMESTAMP = "api_timestamp" + +CONF_VIN = "vin" + +DATA_BATTERY_CAPACITY = "battery_capacity_kwh" + +MANUFACTURER = "Volvo" diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py new file mode 100644 index 00000000000..8ddaaee0781 --- /dev/null +++ b/homeassistant/components/volvo/coordinator.py @@ -0,0 +1,255 @@ +"""Volvo coordinators.""" + +from __future__ import annotations + +from abc import abstractmethod +import asyncio +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any, cast + +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsVehicle, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_BATTERY_CAPACITY, DOMAIN + +VERY_SLOW_INTERVAL = 60 +SLOW_INTERVAL = 15 +MEDIUM_INTERVAL = 2 + +_LOGGER = logging.getLogger(__name__) + + +type VolvoConfigEntry = ConfigEntry[tuple[VolvoBaseCoordinator, ...]] +type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None] + + +class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Volvo base coordinator.""" + + config_entry: VolvoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + update_interval: timedelta, + name: str, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=name, + update_interval=update_interval, + ) + + self.api = api + self.vehicle = vehicle + + self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = [] + + async def _async_setup(self) -> None: + self._api_calls = await self._async_determine_api_calls() + + if not self._api_calls: + self.update_interval = None + + async def _async_update_data(self) -> CoordinatorData: + """Fetch data from API.""" + + data: CoordinatorData = {} + + if not self._api_calls: + return data + + valid = False + exception: Exception | None = None + + results = await asyncio.gather( + *(call() for call in self._api_calls), return_exceptions=True + ) + + for result in results: + if isinstance(result, VolvoAuthException): + # If one result is a VolvoAuthException, then probably all requests + # will fail. In this case we can cancel everything to + # reauthenticate. + # + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + _LOGGER.debug( + "%s - Authentication failed. %s", + self.config_entry.entry_id, + result.message, + ) + raise ConfigEntryAuthFailed( + f"Authentication failed. {result.message}" + ) from result + + if isinstance(result, VolvoApiException): + # Maybe it's just one call that fails. Log the error and + # continue processing the other calls. + _LOGGER.debug( + "%s - Error during data update: %s", + self.config_entry.entry_id, + result.message, + ) + exception = exception or result + continue + + if isinstance(result, Exception): + # Something bad happened, raise immediately. + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from result + + data |= cast(CoordinatorData, result) + valid = True + + # Raise an error if not a single API call succeeded + if not valid: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from exception + + return data + + def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None: + """Get the API field based on the entity description.""" + + return self.data.get(api_field) if api_field else None + + @abstractmethod + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + raise NotImplementedError + + +class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with very slow update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=VERY_SLOW_INTERVAL), + "Volvo very slow interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + return [ + self.api.async_get_diagnostics, + self.api.async_get_odometer, + self.api.async_get_statistics, + ] + + async def _async_update_data(self) -> CoordinatorData: + data = await super()._async_update_data() + + # Add static values + if self.vehicle.has_battery_engine(): + data[DATA_BATTERY_CAPACITY] = VolvoCarsValue.from_dict( + { + "value": self.vehicle.battery_capacity_kwh, + } + ) + + return data + + +class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with slow update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=SLOW_INTERVAL), + "Volvo slow interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + if self.vehicle.has_combustion_engine(): + return [ + self.api.async_get_command_accessibility, + self.api.async_get_fuel_status, + ] + + return [self.api.async_get_command_accessibility] + + +class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with medium update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=MEDIUM_INTERVAL), + "Volvo medium interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + if self.vehicle.has_battery_engine(): + capabilities = await self.api.async_get_energy_capabilities() + + if capabilities.get("isSupported", False): + return [self.api.async_get_energy_state] + + return [] diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py new file mode 100644 index 00000000000..f23bd714870 --- /dev/null +++ b/homeassistant/components/volvo/entity.py @@ -0,0 +1,90 @@ +"""Volvo entity classes.""" + +from abc import abstractmethod +from dataclasses import dataclass + +from volvocarsapi.models import VolvoCarsApiBaseModel + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_VIN, DOMAIN, MANUFACTURER +from .coordinator import VolvoBaseCoordinator + + +def get_unique_id(vin: str, key: str) -> str: + """Get the unique ID.""" + return f"{vin}_{key}".lower() + + +def value_to_translation_key(value: str) -> str: + """Make sure the translation key is valid.""" + return value.lower() + + +@dataclass(frozen=True, kw_only=True) +class VolvoEntityDescription(EntityDescription): + """Describes a Volvo entity.""" + + api_field: str + + +class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator]): + """Volvo base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: VolvoBaseCoordinator, + description: VolvoEntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + + self.entity_description: VolvoEntityDescription = description + + if description.device_class != SensorDeviceClass.BATTERY: + self._attr_translation_key = description.key + + self._attr_unique_id = get_unique_id( + coordinator.config_entry.data[CONF_VIN], description.key + ) + + vehicle = coordinator.vehicle + model = ( + f"{vehicle.description.model} ({vehicle.model_year})" + if vehicle.fuel_type == "NONE" + else f"{vehicle.description.model} {vehicle.fuel_type} ({vehicle.model_year})" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer=MANUFACTURER, + model=model, + name=f"{MANUFACTURER} {vehicle.description.model}", + serial_number=vehicle.vin, + ) + + self._update_state(coordinator.get_api_field(description.api_field)) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + api_field = self.coordinator.get_api_field(self.entity_description.api_field) + self._update_state(api_field) + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + api_field = self.coordinator.get_api_field(self.entity_description.api_field) + return super().available and api_field is not None + + @abstractmethod + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + """Update the state of the entity.""" + raise NotImplementedError diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json new file mode 100644 index 00000000000..8e2897c66ad --- /dev/null +++ b/homeassistant/components/volvo/icons.json @@ -0,0 +1,81 @@ +{ + "entity": { + "sensor": { + "availability": { + "default": "mdi:car-connected" + }, + "average_energy_consumption": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_automatic": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_charge": { + "default": "mdi:car-electric" + }, + "average_fuel_consumption": { + "default": "mdi:gas-station" + }, + "average_fuel_consumption_automatic": { + "default": "mdi:gas-station" + }, + "charger_connection_status": { + "default": "mdi:ev-plug-ccs2" + }, + "charging_power": { + "default": "mdi:gauge-empty", + "range": { + "1": "mdi:gauge-low", + "4200": "mdi:gauge", + "7400": "mdi:gauge-full" + } + }, + "charging_power_status": { + "default": "mdi:power-plug-outline" + }, + "charging_status": { + "default": "mdi:ev-station" + }, + "charging_type": { + "default": "mdi:power-plug-off-outline", + "state": { + "ac": "mdi:current-ac", + "dc": "mdi:current-dc" + } + }, + "distance_to_empty_battery": { + "default": "mdi:gauge-empty" + }, + "distance_to_empty_tank": { + "default": "mdi:gauge-empty" + }, + "distance_to_service": { + "default": "mdi:wrench-clock" + }, + "engine_time_to_service": { + "default": "mdi:wrench-clock" + }, + "estimated_charging_time": { + "default": "mdi:battery-clock" + }, + "fuel_amount": { + "default": "mdi:gas-station" + }, + "odometer": { + "default": "mdi:counter" + }, + "target_battery_charge_level": { + "default": "mdi:battery-medium" + }, + "time_to_service": { + "default": "mdi:wrench-clock" + }, + "trip_meter_automatic": { + "default": "mdi:map-marker-distance" + }, + "trip_meter_manual": { + "default": "mdi:map-marker-distance" + } + } + } +} diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json new file mode 100644 index 00000000000..1530634a10a --- /dev/null +++ b/homeassistant/components/volvo/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "volvo", + "name": "Volvo", + "codeowners": ["@thomasddn"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/volvo", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["volvocarsapi"], + "quality_scale": "silver", + "requirements": ["volvocarsapi==0.4.1"] +} diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml new file mode 100644 index 00000000000..ac91fd001d1 --- /dev/null +++ b/homeassistant/components/volvo/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No discovery possible. + discovery: + status: exempt + comment: | + No discovery possible. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Devices are handpicked because there is a rate limit on the API, which we + would hit if all devices (vehicles) are added under the same API key. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: todo + stale-devices: + status: exempt + comment: | + Devices are handpicked. See dynamic-devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py new file mode 100644 index 00000000000..b8949f5e73d --- /dev/null +++ b/homeassistant/components/volvo/sensor.py @@ -0,0 +1,388 @@ +"""Volvo sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +import logging +from typing import Any, cast + +from volvocarsapi.models import ( + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsValueField, + VolvoCarsValueStatusField, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfEnergyDistance, + UnitOfLength, + UnitOfPower, + UnitOfSpeed, + UnitOfTime, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DATA_BATTERY_CAPACITY +from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry +from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): + """Describes a Volvo sensor entity.""" + + source_fields: list[str] | None = None + value_fn: Callable[[VolvoCarsValue], Any] | None = None + + +def _availability_status(field: VolvoCarsValue) -> str: + reason = field.get("unavailable_reason") + return reason if reason else str(field.value) + + +def _calculate_time_to_service(field: VolvoCarsValue) -> int: + value = int(field.value) + + # Always express value in days + if isinstance(field, VolvoCarsValueField) and field.unit == "months": + return value * 30 + + return value + + +def _charging_power_value(field: VolvoCarsValue) -> int: + return ( + int(field.value) + if isinstance(field, VolvoCarsValueStatusField) and field.status == "OK" + else 0 + ) + + +def _charging_power_status_value(field: VolvoCarsValue) -> str | None: + status = cast(str, field.value) + + if status.lower() in _CHARGING_POWER_STATUS_OPTIONS: + return status + + _LOGGER.warning( + "Unknown value '%s' for charging_power_status. Please report it at https://github.com/home-assistant/core/issues/new?template=bug_report.yml", + status, + ) + return None + + +_CHARGING_POWER_STATUS_OPTIONS = ["providing_power", "no_power_available"] + +_DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( + # command-accessibility endpoint + VolvoSensorDescription( + key="availability", + api_field="availabilityStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "available", + "car_in_use", + "no_internet", + "ota_installation_in_progress", + "power_saving_mode", + ], + value_fn=_availability_status, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption", + api_field="averageEnergyConsumption", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_automatic", + api_field="averageEnergyConsumptionAutomatic", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_charge", + api_field="averageEnergyConsumptionSinceCharge", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption", + api_field="averageFuelConsumption", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption_automatic", + api_field="averageFuelConsumptionAutomatic", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed", + api_field="averageSpeed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed_automatic", + api_field="averageSpeedAutomatic", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + # vehicle endpoint + VolvoSensorDescription( + key="battery_capacity", + api_field=DATA_BATTERY_CAPACITY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # fuel & energy state endpoint + VolvoSensorDescription( + key="battery_charge_level", + api_field="batteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + # energy state endpoint + VolvoSensorDescription( + key="charger_connection_status", + api_field="chargerConnectionStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "connected", + "disconnected", + "fault", + ], + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_current_limit", + api_field="chargingCurrentLimit", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_power", + api_field="chargingPower", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=_charging_power_value, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_power_status", + api_field="chargerPowerStatus", + device_class=SensorDeviceClass.ENUM, + options=_CHARGING_POWER_STATUS_OPTIONS, + value_fn=_charging_power_status_value, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_status", + api_field="chargingStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "charging", + "discharging", + "done", + "error", + "idle", + "scheduled", + ], + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_type", + api_field="chargingType", + device_class=SensorDeviceClass.ENUM, + options=[ + "ac", + "dc", + "none", + ], + ), + # statistics & energy state endpoint + VolvoSensorDescription( + key="distance_to_empty_battery", + api_field="", + source_fields=["distanceToEmptyBattery", "electricRange"], + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="distance_to_empty_tank", + api_field="distanceToEmptyTank", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="distance_to_service", + api_field="distanceToService", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="engine_time_to_service", + api_field="engineHoursToService", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + # energy state endpoint + VolvoSensorDescription( + key="estimated_charging_time", + api_field="estimatedChargingTimeToTargetBatteryChargeLevel", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + # fuel endpoint + VolvoSensorDescription( + key="fuel_amount", + api_field="fuelAmount", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # odometer endpoint + VolvoSensorDescription( + key="odometer", + api_field="odometer", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + # energy state endpoint + VolvoSensorDescription( + key="target_battery_charge_level", + api_field="targetBatteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="time_to_service", + api_field="timeToService", + native_unit_of_measurement=UnitOfTime.DAYS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=_calculate_time_to_service, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_automatic", + api_field="tripMeterAutomatic", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_manual", + api_field="tripMeterManual", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors.""" + + entities: list[VolvoSensor] = [] + added_keys: set[str] = set() + + def _add_entity( + coordinator: VolvoBaseCoordinator, description: VolvoSensorDescription + ) -> None: + entities.append(VolvoSensor(coordinator, description)) + added_keys.add(description.key) + + coordinators = entry.runtime_data + + for coordinator in coordinators: + for description in _DESCRIPTIONS: + if description.key in added_keys: + continue + + if description.source_fields: + for field in description.source_fields: + if field in coordinator.data: + description = replace(description, api_field=field) + _add_entity(coordinator, description) + elif description.api_field in coordinator.data: + _add_entity(coordinator, description) + + async_add_entities(entities) + + +class VolvoSensor(VolvoEntity, SensorEntity): + """Volvo sensor.""" + + entity_description: VolvoSensorDescription + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + """Update the state of the entity.""" + if api_field is None: + self._attr_native_value = None + return + + assert isinstance(api_field, VolvoCarsValue) + + native_value = ( + api_field.value + if self.entity_description.value_fn is None + else self.entity_description.value_fn(api_field) + ) + + if self.device_class == SensorDeviceClass.ENUM and native_value: + # Entities having an "unknown" value should report None as the state + native_value = str(native_value) + native_value = ( + value_to_translation_key(native_value) + if native_value.upper() != "UNSPECIFIED" + else None + ) + + self._attr_native_value = native_value diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json new file mode 100644 index 00000000000..4fe7429117c --- /dev/null +++ b/homeassistant/components/volvo/strings.json @@ -0,0 +1,178 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Volvo integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "api_key": { + "description": "Get your API key from the [Volvo developer portal]({volvo_dev_portal}).", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The Volvo developers API key" + } + }, + "vin": { + "description": "Select a vehicle", + "data": { + "vin": "VIN" + }, + "data_description": { + "vin": "The Vehicle Identification Number of the vehicle you want to add" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "error": { + "cannot_load_vehicles": "Unable to retrieve vehicles.", + "no_vehicles": "No vehicles found on this account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "sensor": { + "availability": { + "name": "Car connection", + "state": { + "available": "Available", + "car_in_use": "Car is in use", + "no_internet": "No internet", + "ota_installation_in_progress": "Installing OTA update", + "power_saving_mode": "Power saving mode", + "unavailable": "Unavailable" + } + }, + "average_energy_consumption": { + "name": "Trip manual average energy consumption" + }, + "average_energy_consumption_automatic": { + "name": "Trip automatic average energy consumption" + }, + "average_energy_consumption_charge": { + "name": "Average energy consumption since charge" + }, + "average_fuel_consumption": { + "name": "Trip manual average fuel consumption" + }, + "average_fuel_consumption_automatic": { + "name": "Trip automatic average fuel consumption" + }, + "average_speed": { + "name": "Trip manual average speed" + }, + "average_speed_automatic": { + "name": "Trip automatic average speed" + }, + "battery_capacity": { + "name": "Battery capacity" + }, + "battery_charge_level": { + "name": "Battery charge level" + }, + "charger_connection_status": { + "name": "Charging connection status", + "state": { + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", + "fault": "[%key:common::state::error%]" + } + }, + "charging_current_limit": { + "name": "Charging limit" + }, + "charging_power": { + "name": "Charging power" + }, + "charging_power_status": { + "name": "Charging power status", + "state": { + "providing_power": "Providing power", + "no_power_available": "No power" + } + }, + "charging_status": { + "name": "Charging status", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "done": "Done", + "error": "[%key:common::state::error%]", + "idle": "[%key:common::state::idle%]", + "scheduled": "Scheduled" + } + }, + "charging_type": { + "name": "Charging type", + "state": { + "ac": "AC", + "dc": "DC", + "none": "None" + } + }, + "distance_to_empty_battery": { + "name": "Distance to empty battery" + }, + "distance_to_empty_tank": { + "name": "Distance to empty tank" + }, + "distance_to_service": { + "name": "Distance to service" + }, + "engine_time_to_service": { + "name": "Time to engine service" + }, + "estimated_charging_time": { + "name": "Estimated charging time" + }, + "fuel_amount": { + "name": "Fuel amount" + }, + "odometer": { + "name": "Odometer" + }, + "target_battery_charge_level": { + "name": "Target battery charge level" + }, + "time_to_service": { + "name": "Time to service" + }, + "trip_meter_automatic": { + "name": "Trip automatic distance" + }, + "trip_meter_manual": { + "name": "Trip manual distance" + } + } + }, + "exceptions": { + "no_vehicle": { + "message": "Unable to retrieve vehicle details." + }, + "update_failed": { + "message": "Unable to update data." + }, + "unauthorized": { + "message": "Authentication failed. {message}" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 2f088716f8c..0abd4365feb 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -35,6 +35,7 @@ APPLICATION_CREDENTIALS = [ "spotify", "tesla_fleet", "twitch", + "volvo", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d9fd32d204b..5d468fd1dc9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -701,6 +701,7 @@ FLOWS = { "vodafone_station", "voip", "volumio", + "volvo", "volvooncall", "vulcan", "wake_on_lan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 24f72add2ec..a673b05218d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7267,6 +7267,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "volvo": { + "name": "Volvo", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "volvooncall": { "name": "Volvo On Call", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index bfd9cfb0a84..ba5ac08d3c9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5229,6 +5229,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.volvo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 41947853270..943321cbf31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3059,6 +3059,9 @@ voip-utils==0.3.3 # homeassistant.components.volkszaehler volkszaehler==0.4.0 +# homeassistant.components.volvo +volvocarsapi==0.4.1 + # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c807c93a6c5..de76a345a62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2524,6 +2524,9 @@ vilfo-api-client==0.5.0 # homeassistant.components.voip voip-utils==0.3.3 +# homeassistant.components.volvo +volvocarsapi==0.4.1 + # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py new file mode 100644 index 00000000000..875052fcf7e --- /dev/null +++ b/tests/components/volvo/__init__.py @@ -0,0 +1,52 @@ +"""Tests for the Volvo integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from volvocarsapi.models import VolvoCarsValueField + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType, json_loads_object + +from tests.common import async_load_fixture + +_MODEL_SPECIFIC_RESPONSES = { + "ex30_2024": ["energy_capabilities", "energy_state", "statistics", "vehicle"], + "s90_diesel_2018": ["diagnostics", "statistics", "vehicle"], + "xc40_electric_2024": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], + "xc90_petrol_2019": ["commands", "statistics", "vehicle"], +} + + +async def async_load_fixture_as_json( + hass: HomeAssistant, name: str, model: str +) -> JsonObjectType: + """Load a JSON object from a fixture.""" + if name in _MODEL_SPECIFIC_RESPONSES[model]: + name = f"{model}/{name}" + + fixture = await async_load_fixture(hass, f"{name}.json", DOMAIN) + return json_loads_object(fixture) + + +async def async_load_fixture_as_value_field( + hass: HomeAssistant, name: str, model: str +) -> dict[str, VolvoCarsValueField]: + """Load a `VolvoCarsValueField` object from a fixture.""" + data = await async_load_fixture_as_json(hass, name, model) + return {key: VolvoCarsValueField.from_dict(value) for key, value in data.items()} + + +def configure_mock( + mock: AsyncMock, *, return_value: Any = None, side_effect: Any = None +) -> None: + """Reconfigure mock.""" + mock.reset_mock() + mock.side_effect = side_effect + mock.return_value = return_value diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py new file mode 100644 index 00000000000..edd3f39998e --- /dev/null +++ b/tests/components/volvo/conftest.py @@ -0,0 +1,185 @@ +"""Define fixtures for Volvo unit tests.""" + +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from unittest.mock import AsyncMock, patch + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import TOKEN_URL +from volvocarsapi.models import ( + VolvoCarsAvailableCommand, + VolvoCarsLocation, + VolvoCarsValueField, + VolvoCarsVehicle, +) + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import async_load_fixture_as_json, async_load_fixture_as_value_field +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DEFAULT_VIN, + MOCK_ACCESS_TOKEN, + SERVER_TOKEN_RESPONSE, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(params=[DEFAULT_MODEL]) +def full_model(request: pytest.FixtureRequest) -> str: + """Define which model to use when running the test. Use as a decorator.""" + return request.param + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DEFAULT_VIN, + data={ + "auth_implementation": DOMAIN, + CONF_API_KEY: DEFAULT_API_KEY, + CONF_VIN: DEFAULT_VIN, + CONF_TOKEN: { + "access_token": MOCK_ACCESS_TOKEN, + "refresh_token": "mock-refresh-token", + "expires_at": 123456789, + }, + }, + ) + + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(autouse=True) +async def mock_api(hass: HomeAssistant, full_model: str) -> AsyncGenerator[AsyncMock]: + """Mock the Volvo API.""" + with patch( + "homeassistant.components.volvo.VolvoCarsApi", + autospec=True, + ) as mock_api: + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) + vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + + commands_data = ( + await async_load_fixture_as_json(hass, "commands", full_model) + ).get("data") + commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] + + location_data = await async_load_fixture_as_json(hass, "location", full_model) + location = {"location": VolvoCarsLocation.from_dict(location_data)} + + availability = await async_load_fixture_as_value_field( + hass, "availability", full_model + ) + brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model) + diagnostics = await async_load_fixture_as_value_field( + hass, "diagnostics", full_model + ) + doors = await async_load_fixture_as_value_field(hass, "doors", full_model) + energy_capabilities = await async_load_fixture_as_json( + hass, "energy_capabilities", full_model + ) + energy_state_data = await async_load_fixture_as_json( + hass, "energy_state", full_model + ) + energy_state = { + key: VolvoCarsValueField.from_dict(value) + for key, value in energy_state_data.items() + } + engine_status = await async_load_fixture_as_value_field( + hass, "engine_status", full_model + ) + engine_warnings = await async_load_fixture_as_value_field( + hass, "engine_warnings", full_model + ) + fuel_status = await async_load_fixture_as_value_field( + hass, "fuel_status", full_model + ) + odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model) + recharge_status = await async_load_fixture_as_value_field( + hass, "recharge_status", full_model + ) + statistics = await async_load_fixture_as_value_field( + hass, "statistics", full_model + ) + tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model) + warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model) + windows = await async_load_fixture_as_value_field(hass, "windows", full_model) + + api: VolvoCarsApi = mock_api.return_value + api.async_get_brakes_status = AsyncMock(return_value=brakes) + api.async_get_command_accessibility = AsyncMock(return_value=availability) + api.async_get_commands = AsyncMock(return_value=commands) + api.async_get_diagnostics = AsyncMock(return_value=diagnostics) + api.async_get_doors_status = AsyncMock(return_value=doors) + api.async_get_energy_capabilities = AsyncMock(return_value=energy_capabilities) + api.async_get_energy_state = AsyncMock(return_value=energy_state) + api.async_get_engine_status = AsyncMock(return_value=engine_status) + api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) + api.async_get_fuel_status = AsyncMock(return_value=fuel_status) + api.async_get_location = AsyncMock(return_value=location) + api.async_get_odometer = AsyncMock(return_value=odometer) + api.async_get_recharge_status = AsyncMock(return_value=recharge_status) + api.async_get_statistics = AsyncMock(return_value=statistics) + api.async_get_tyre_states = AsyncMock(return_value=tyres) + api.async_get_vehicle_details = AsyncMock(return_value=vehicle) + api.async_get_warnings = AsyncMock(return_value=warnings) + api.async_get_window_states = AsyncMock(return_value=windows) + + yield api + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + + async def run() -> bool: + aioclient_mock.post( + TOKEN_URL, + json=SERVER_TOKEN_RESPONSE, + ) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.volvo.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/volvo/const.py b/tests/components/volvo/const.py new file mode 100644 index 00000000000..df18bacb2b0 --- /dev/null +++ b/tests/components/volvo/const.py @@ -0,0 +1,19 @@ +"""Define const for Volvo unit tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +DEFAULT_API_KEY = "abcdef0123456879abcdef" +DEFAULT_MODEL = "xc40_electric_2024" +DEFAULT_VIN = "YV1ABCDEFG1234567" + +MOCK_ACCESS_TOKEN = "mock-access-token" + +REDIRECT_URI = "https://example.com/auth/external/callback" + +SERVER_TOKEN_RESPONSE = { + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", + "token_type": "Bearer", + "expires_in": 60, +} diff --git a/tests/components/volvo/fixtures/availability.json b/tests/components/volvo/fixtures/availability.json new file mode 100644 index 00000000000..264f4d54360 --- /dev/null +++ b/tests/components/volvo/fixtures/availability.json @@ -0,0 +1,6 @@ +{ + "availabilityStatus": { + "value": "AVAILABLE", + "timestamp": "2024-12-30T14:32:26.169Z" + } +} diff --git a/tests/components/volvo/fixtures/brakes.json b/tests/components/volvo/fixtures/brakes.json new file mode 100644 index 00000000000..6fe3b3b328c --- /dev/null +++ b/tests/components/volvo/fixtures/brakes.json @@ -0,0 +1,6 @@ +{ + "brakeFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/commands.json b/tests/components/volvo/fixtures/commands.json new file mode 100644 index 00000000000..5d21861801f --- /dev/null +++ b/tests/components/volvo/fixtures/commands.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/diagnostics.json b/tests/components/volvo/fixtures/diagnostics.json new file mode 100644 index 00000000000..100af71b9e3 --- /dev/null +++ b/tests/components/volvo/fixtures/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 23, + "unit": "months", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/doors.json b/tests/components/volvo/fixtures/doors.json new file mode 100644 index 00000000000..268d9fec467 --- /dev/null +++ b/tests/components/volvo/fixtures/doors.json @@ -0,0 +1,34 @@ +{ + "centralLock": { + "value": "LOCKED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "hood": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tailgate": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tankLid": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + } +} diff --git a/tests/components/volvo/fixtures/energy_capabilities.json b/tests/components/volvo/fixtures/energy_capabilities.json new file mode 100644 index 00000000000..16ba914e343 --- /dev/null +++ b/tests/components/volvo/fixtures/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": false, + "batteryChargeLevel": { + "isSupported": false + }, + "electricRange": { + "isSupported": false + }, + "chargerConnectionStatus": { + "isSupported": false + }, + "chargingSystemStatus": { + "isSupported": false + }, + "chargingType": { + "isSupported": false + }, + "chargerPowerStatus": { + "isSupported": false + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": false + }, + "targetBatteryChargeLevel": { + "isSupported": false + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/energy_state.json b/tests/components/volvo/fixtures/energy_state.json new file mode 100644 index 00000000000..31d717c4cce --- /dev/null +++ b/tests/components/volvo/fixtures/energy_state.json @@ -0,0 +1,42 @@ +{ + "batteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "electricRange": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargerConnectionStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingType": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargerPowerStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "targetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + } +} diff --git a/tests/components/volvo/fixtures/engine_status.json b/tests/components/volvo/fixtures/engine_status.json new file mode 100644 index 00000000000..daac36b6a26 --- /dev/null +++ b/tests/components/volvo/fixtures/engine_status.json @@ -0,0 +1,6 @@ +{ + "engineStatus": { + "value": "STOPPED", + "timestamp": "2024-12-30T15:00:00.000Z" + } +} diff --git a/tests/components/volvo/fixtures/engine_warnings.json b/tests/components/volvo/fixtures/engine_warnings.json new file mode 100644 index 00000000000..d431355fd24 --- /dev/null +++ b/tests/components/volvo/fixtures/engine_warnings.json @@ -0,0 +1,10 @@ +{ + "oilLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineCoolantLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json new file mode 100644 index 00000000000..968c759ab27 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": true + }, + "chargingPower": { + "isSupported": true + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_state.json b/tests/components/volvo/fixtures/ex30_2024/energy_state.json new file mode 100644 index 00000000000..fe42dba568a --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/energy_state.json @@ -0,0 +1,57 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 38, + "unit": "percentage", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "electricRange": { + "status": "OK", + "value": 90, + "unit": "km", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "DISCONNECTED", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingStatus": { + "status": "OK", + "value": "IDLE", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingType": { + "status": "OK", + "value": "NONE", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "NO_POWER_AVAILABLE", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 0, + "unit": "minutes", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingCurrentLimit": { + "status": "OK", + "value": 32, + "unit": "ampere", + "updatedAt": "2024-03-05T08:38:44Z" + }, + "targetBatteryChargeLevel": { + "status": "OK", + "value": 90, + "unit": "percentage", + "updatedAt": "2024-09-22T09:40:12Z" + }, + "chargingPower": { + "status": "ERROR", + "code": "PROPERTY_NOT_FOUND", + "message": "No valid value could be found for the requested property" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/statistics.json b/tests/components/volvo/fixtures/ex30_2024/statistics.json new file mode 100644 index 00000000000..9e2f32bdcf2 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/statistics.json @@ -0,0 +1,32 @@ +{ + "averageEnergyConsumption": { + "value": 22.6, + "unit": "kWh/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyBattery": { + "value": 250, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/vehicle.json b/tests/components/volvo/fixtures/ex30_2024/vehicle.json new file mode 100644 index 00000000000..dc47b5bb341 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "NONE", + "externalColour": "Crystal White Pearl", + "batteryCapacityKWH": 66.0, + "images": { + "exteriorImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/exterior/studio/right/transparent_exterior-studio-right_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920", + "internalImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/interior/studio/side/interior-studio-side_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920" + }, + "descriptions": { + "model": "EX30", + "upholstery": "R310", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/fuel_status.json b/tests/components/volvo/fixtures/fuel_status.json new file mode 100644 index 00000000000..a55f14467fe --- /dev/null +++ b/tests/components/volvo/fixtures/fuel_status.json @@ -0,0 +1,12 @@ +{ + "fuelAmount": { + "value": "47.3", + "unit": "l", + "timestamp": "2020-11-19T21:23:24.123Z" + }, + "batteryChargeLevel": { + "value": "87.3", + "unit": "%", + "timestamp": "2020-11-19T21:23:24.123Z" + } +} diff --git a/tests/components/volvo/fixtures/location.json b/tests/components/volvo/fixtures/location.json new file mode 100644 index 00000000000..eec49f8a66b --- /dev/null +++ b/tests/components/volvo/fixtures/location.json @@ -0,0 +1,11 @@ +{ + "type": "Feature", + "properties": { + "timestamp": "2024-12-30T15:00:00.000Z", + "heading": "90" + }, + "geometry": { + "type": "Point", + "coordinates": [11.849843629550225, 57.72537482589284, 0.0] + } +} diff --git a/tests/components/volvo/fixtures/odometer.json b/tests/components/volvo/fixtures/odometer.json new file mode 100644 index 00000000000..a9196faaa7d --- /dev/null +++ b/tests/components/volvo/fixtures/odometer.json @@ -0,0 +1,7 @@ +{ + "odometer": { + "value": 30000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/recharge_status.json b/tests/components/volvo/fixtures/recharge_status.json new file mode 100644 index 00000000000..5e9fed0803c --- /dev/null +++ b/tests/components/volvo/fixtures/recharge_status.json @@ -0,0 +1,25 @@ +{ + "estimatedChargingTime": { + "value": "780", + "unit": "minutes", + "timestamp": "2024-12-30T14:30:08Z" + }, + "batteryChargeLevel": { + "value": "58.0", + "unit": "percentage", + "timestamp": "2024-12-30T14:30:08Z" + }, + "electricRange": { + "value": "250", + "unit": "kilometers", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingSystemStatus": { + "value": "CHARGING_SYSTEM_IDLE", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingConnectionStatus": { + "value": "CONNECTION_STATUS_CONNECTED_AC", + "timestamp": "2024-12-30T14:30:08Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json new file mode 100644 index 00000000000..738eb3c8966 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 17, + "unit": "days", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json new file mode 100644 index 00000000000..9f6760451ed --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 7.23, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyTank": { + "value": 147, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json new file mode 100644 index 00000000000..429964991e7 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2018, + "gearbox": "AUTOMATIC", + "fuelType": "DIESEL", + "externalColour": "Electric Silver", + "images": { + "exteriorImageUrl": "", + "internalImageUrl": "" + }, + "descriptions": { + "model": "S90", + "upholstery": "null", + "steering": "RIGHT" + } +} diff --git a/tests/components/volvo/fixtures/tyres.json b/tests/components/volvo/fixtures/tyres.json new file mode 100644 index 00000000000..c414c85203f --- /dev/null +++ b/tests/components/volvo/fixtures/tyres.json @@ -0,0 +1,18 @@ +{ + "frontLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "frontRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/warnings.json b/tests/components/volvo/fixtures/warnings.json new file mode 100644 index 00000000000..5bec30ed4b3 --- /dev/null +++ b/tests/components/volvo/fixtures/warnings.json @@ -0,0 +1,94 @@ +{ + "brakeLightCenterWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightFrontWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightRearWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "registrationPlateLightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "sideMarkLightsWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "hazardLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "reverseLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/windows.json b/tests/components/volvo/fixtures/windows.json new file mode 100644 index 00000000000..cd399b3bbe8 --- /dev/null +++ b/tests/components/volvo/fixtures/windows.json @@ -0,0 +1,22 @@ +{ + "frontLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "frontRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "sunroof": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:28:12.202Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json new file mode 100644 index 00000000000..968c759ab27 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": true + }, + "chargingPower": { + "isSupported": true + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json new file mode 100644 index 00000000000..16208571c47 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json @@ -0,0 +1,58 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 53, + "unit": "percentage", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "electricRange": { + "status": "OK", + "value": 220, + "unit": "km", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "CONNECTED", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingStatus": { + "status": "OK", + "value": "CHARGING", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingType": { + "status": "OK", + "value": "AC", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "PROVIDING_POWER", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 1440, + "unit": "minutes", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingCurrentLimit": { + "status": "OK", + "value": 32, + "unit": "ampere", + "updatedAt": "2024-03-05T08:38:44Z" + }, + "targetBatteryChargeLevel": { + "status": "OK", + "value": 90, + "unit": "percentage", + "updatedAt": "2024-09-22T09:40:12Z" + }, + "chargingPower": { + "status": "OK", + "value": 1386, + "unit": "watts", + "updatedAt": "2025-07-02T08:51:23Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json b/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json new file mode 100644 index 00000000000..9e2f32bdcf2 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json @@ -0,0 +1,32 @@ +{ + "averageEnergyConsumption": { + "value": 22.6, + "unit": "kWh/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyBattery": { + "value": 250, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json new file mode 100644 index 00000000000..8b36c06f681 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "ELECTRIC", + "externalColour": "Silver Dawn", + "batteryCapacityKWH": 81.608, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/exterior-v4/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/interior-v4/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC40", + "upholstery": "null", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json new file mode 100644 index 00000000000..8f5e62df1ed --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json @@ -0,0 +1,44 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + }, + { + "command": "ENGINE_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-start" + }, + { + "command": "ENGINE_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json b/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json new file mode 100644 index 00000000000..1a7744a4d49 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 9.59, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 66, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 77, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyTank": { + "value": 253, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + }, + "tripMeterManual": { + "value": 178.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 4.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json new file mode 100644 index 00000000000..1d4b1250b8a --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2019, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL", + "externalColour": "Passion Red Solid", + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/exterior/MY17_0000/123/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/interior/MY17_0000/123/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC90", + "upholstery": "CHARCOAL/LEABR/CHARC/S", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..0f79ab5ca07 --- /dev/null +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -0,0 +1,3833 @@ +# serializer version: 1 +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo EX30 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_ex30_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo EX30 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66.0', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disconnected', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging limit', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_current_limit', + 'unique_id': 'yv1abcdefg1234567_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Volvo EX30 Charging limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'yv1abcdefg1234567_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Volvo EX30 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_power_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging power status', + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_power_available', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_estimated_charging_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_target_battery_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo EX30 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.6', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo EX30 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo S90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo S90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '147', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_fuel_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo S90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo S90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo S90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo S90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.23', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo S90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.volvo_xc40_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC40 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '81.608', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging limit', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_current_limit', + 'unique_id': 'yv1abcdefg1234567_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Volvo XC40 Charging limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'yv1abcdefg1234567_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Volvo XC40 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_power_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging power status', + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'providing_power', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ac', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_estimated_charging_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1440', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_target_battery_charge_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_target_battery_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC40 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.6', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC40 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '253', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_fuel_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_odometer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_engine_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '77', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.2', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_fuel_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.59', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '178.9', + }) +# --- diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py new file mode 100644 index 00000000000..91a7803dce5 --- /dev/null +++ b/tests/components/volvo/test_config_flow.py @@ -0,0 +1,303 @@ +"""Test the Volvo config flow.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL +from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle +from volvocarsapi.scopes import DEFAULT_SCOPES +from yarl import URL + +from homeassistant import config_entries +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import async_load_fixture_as_json, configure_mock +from .const import ( + CLIENT_ID, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DEFAULT_VIN, + REDIRECT_URI, + SERVER_TOKEN_RESPONSE, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check full flow.""" + result = await _async_run_flow_to_completion( + hass, config_flow, mock_config_flow_api + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_API_KEY] == DEFAULT_API_KEY + assert result["data"][CONF_VIN] == DEFAULT_VIN + assert result["context"]["unique_id"] == DEFAULT_VIN + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_single_vin_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check flow where API returns a single VIN.""" + _configure_mock_vehicles_success(mock_config_flow_api, single_vin=True) + + # Since there is only one VIN, the api_key step is the only step + result = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize(("api_key_failure"), [pytest.param(True), pytest.param(False)]) +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + mock_config_flow_api: VolvoCarsApi, + api_key_failure: bool, +) -> None: + """Test reauthentication flow.""" + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await _async_run_flow_to_completion( + hass, + result, + mock_config_flow_api, + has_vin_step=False, + is_reauth=True, + api_key_failure=api_key_failure, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test reconfiguration flow.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.usefixtures("current_request_with_host", "mock_config_entry") +async def test_unique_id_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test unique ID flow.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await _async_run_flow_to_completion( + hass, config_flow, mock_config_flow_api + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_api_failure_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check flow where API throws an exception.""" + _configure_mock_vehicles_failure(mock_config_flow_api) + + result = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_load_vehicles" + assert result["step_id"] == "api_key" + + result = await _async_run_flow_to_completion( + hass, result, mock_config_flow_api, configure=False + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.fixture +async def config_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> config_entries.ConfigFlowResult: + """Initialize a new config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + result_url = URL(result["url"]) + assert f"{result_url.origin()}{result_url.path}" == AUTHORIZE_URL + assert result_url.query["response_type"] == "code" + assert result_url.query["client_id"] == CLIENT_ID + assert result_url.query["redirect_uri"] == REDIRECT_URI + assert result_url.query["state"] == state + assert result_url.query["code_challenge"] + assert result_url.query["code_challenge_method"] == "S256" + assert result_url.query["scope"] == " ".join(DEFAULT_SCOPES) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + return result + + +@pytest.fixture +async def mock_config_flow_api(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock API used in config flow.""" + with patch( + "homeassistant.components.volvo.config_flow.VolvoCarsApi", + autospec=True, + ) as mock_api: + api: VolvoCarsApi = mock_api.return_value + + _configure_mock_vehicles_success(api) + + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", DEFAULT_MODEL) + configure_mock( + api.async_get_vehicle_details, + return_value=VolvoCarsVehicle.from_dict(vehicle_data), + ) + + yield api + + +@pytest.fixture(autouse=True) +async def mock_auth_client( + aioclient_mock: AiohttpClientMocker, +) -> AsyncGenerator[AsyncMock]: + """Mock auth requests.""" + aioclient_mock.clear_requests() + aioclient_mock.post( + TOKEN_URL, + json=SERVER_TOKEN_RESPONSE, + ) + + +async def _async_run_flow_to_completion( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, + *, + configure: bool = True, + has_vin_step: bool = True, + is_reauth: bool = False, + api_key_failure: bool = False, +) -> ConfigFlowResult: + if configure: + if api_key_failure: + _configure_mock_vehicles_failure(mock_config_flow_api) + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"] + ) + + if is_reauth and not api_key_failure: + return config_flow + + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "api_key" + + _configure_mock_vehicles_success(mock_config_flow_api) + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + if has_vin_step: + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "vin" + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_VIN: DEFAULT_VIN} + ) + + return config_flow + + +def _configure_mock_vehicles_success( + mock_config_flow_api: VolvoCarsApi, single_vin: bool = False +) -> None: + vins = [{"vin": DEFAULT_VIN}] + + if not single_vin: + vins.append({"vin": "YV10000000AAAAAAA"}) + + configure_mock(mock_config_flow_api.async_get_vehicles, return_value=vins) + + +def _configure_mock_vehicles_failure(mock_config_flow_api: VolvoCarsApi) -> None: + configure_mock( + mock_config_flow_api.async_get_vehicles, side_effect=VolvoApiException() + ) diff --git a/tests/components/volvo/test_coordinator.py b/tests/components/volvo/test_coordinator.py new file mode 100644 index 00000000000..271693a18d1 --- /dev/null +++ b/tests/components/volvo/test_coordinator.py @@ -0,0 +1,151 @@ +"""Test Volvo coordinator.""" + +from collections.abc import Awaitable, Callable +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsValueField, +) + +from homeassistant.components.volvo.coordinator import VERY_SLOW_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import configure_mock + +from tests.common import async_fire_time_changed + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_coordinator_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test coordinator update.""" + assert await setup_integration() + + sensor_id = "sensor.volvo_xc40_odometer" + interval = timedelta(minutes=VERY_SLOW_INTERVAL) + value = {"odometer": VolvoCarsValueField(value=30000, unit="km")} + mock_method: AsyncMock = mock_api.async_get_odometer + + state = hass.states.get(sensor_id) + assert state.state == "30000" + + value["odometer"].value = 30001 + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30001" + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_coordinator_with_errors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test coordinator with errors.""" + assert await setup_integration() + + sensor_id = "sensor.volvo_xc40_odometer" + interval = timedelta(minutes=VERY_SLOW_INTERVAL) + value = {"odometer": VolvoCarsValueField(value=30000, unit="km")} + mock_method: AsyncMock = mock_api.async_get_odometer + + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=VolvoApiException()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=Exception()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=VolvoAuthException()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_update_coordinator_all_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test API returning error for all calls during coordinator update.""" + assert await setup_integration() + + _mock_api_failure(mock_api) + freezer.tick(timedelta(minutes=VERY_SLOW_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for state in hass.states.async_all(): + assert state.state == STATE_UNAVAILABLE + + +def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock: + """Mock the Volvo API so that it raises an exception for all calls.""" + + mock_api.async_get_brakes_status.side_effect = VolvoApiException() + mock_api.async_get_command_accessibility.side_effect = VolvoApiException() + mock_api.async_get_commands.side_effect = VolvoApiException() + mock_api.async_get_diagnostics.side_effect = VolvoApiException() + mock_api.async_get_doors_status.side_effect = VolvoApiException() + mock_api.async_get_energy_capabilities.side_effect = VolvoApiException() + mock_api.async_get_energy_state.side_effect = VolvoApiException() + mock_api.async_get_engine_status.side_effect = VolvoApiException() + mock_api.async_get_engine_warnings.side_effect = VolvoApiException() + mock_api.async_get_fuel_status.side_effect = VolvoApiException() + mock_api.async_get_location.side_effect = VolvoApiException() + mock_api.async_get_odometer.side_effect = VolvoApiException() + mock_api.async_get_recharge_status.side_effect = VolvoApiException() + mock_api.async_get_statistics.side_effect = VolvoApiException() + mock_api.async_get_tyre_states.side_effect = VolvoApiException() + mock_api.async_get_warnings.side_effect = VolvoApiException() + mock_api.async_get_window_states.side_effect = VolvoApiException() + + return mock_api diff --git a/tests/components/volvo/test_init.py b/tests/components/volvo/test_init.py new file mode 100644 index 00000000000..e0e6c74b839 --- /dev/null +++ b/tests/components/volvo/test_init.py @@ -0,0 +1,125 @@ +"""Test Volvo init.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from unittest.mock import AsyncMock + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import TOKEN_URL +from volvocarsapi.models import VolvoAuthException + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from . import configure_mock +from .const import MOCK_ACCESS_TOKEN, SERVER_TOKEN_RESPONSE + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test setting up the integration.""" + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_token_refresh_success( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh succeeds.""" + + assert mock_config_entry.data[CONF_TOKEN]["access_token"] == MOCK_ACCESS_TOKEN + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify token + assert len(aioclient_mock.mock_calls) == 1 + assert ( + mock_config_entry.data[CONF_TOKEN]["access_token"] + == SERVER_TOKEN_RESPONSE["access_token"] + ) + + +@pytest.mark.parametrize( + ("token_response"), + [ + (HTTPStatus.FORBIDDEN), + (HTTPStatus.INTERNAL_SERVER_ERROR), + (HTTPStatus.NOT_FOUND), + ], +) +async def test_token_refresh_fail( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], + token_response: HTTPStatus, +) -> None: + """Test where token refresh fails.""" + + aioclient_mock.post(TOKEN_URL, status=token_response) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_token_refresh_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh indicates unauthorized.""" + + aioclient_mock.post(TOKEN_URL, status=HTTPStatus.UNAUTHORIZED) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert flows + assert flows[0]["handler"] == DOMAIN + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_no_vehicle( + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test no vehicle during coordinator setup.""" + mock_method: AsyncMock = mock_api.async_get_vehicle_details + + configure_mock(mock_method, return_value=None, side_effect=None) + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_vehicle_auth_failure( + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test auth failure during coordinator setup.""" + mock_method: AsyncMock = mock_api.async_get_vehicle_details + + configure_mock(mock_method, return_value=None, side_effect=VolvoAuthException()) + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py new file mode 100644 index 00000000000..f610ee2ed57 --- /dev/null +++ b/tests/components/volvo/test_sensor.py @@ -0,0 +1,32 @@ +"""Test Volvo sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "full_model", + ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], +) +async def test_sensor( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From e518e7beaca1f14ad104b9ed8fec84e1d5246d8a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:42:18 +0200 Subject: [PATCH 215/235] Add service tests to Tuya select platform (#149156) Co-authored-by: Joost Lekkerkerker --- tests/components/tuya/test_select.py | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index c295a07d83f..cd1d926ff76 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -8,9 +8,14 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -53,3 +58,62 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_select_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": entity_id, + "option": "forward", + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "control_back_mode", "value": "forward"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_select_invalid_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": entity_id, + "option": "hello", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_option" From 92ad922ddc33527456f76aa87ed493d53fdf8426 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:42:36 +0200 Subject: [PATCH 216/235] Add fan mode support for Tuya air conditioner (aqoouq7x) (#149226) --- homeassistant/components/tuya/climate.py | 2 +- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/wk_air_conditioner.json | 102 ++++++++++++++++++ .../tuya/snapshots/test_climate.ambr | 81 ++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++ 5 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/wk_air_conditioner.json diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 370548d67b0..c8071e68397 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -252,7 +252,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): # Determine fan modes self._fan_mode_dp_code: str | None = None if enum_type := self.find_dpcode( - (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + (DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED), dptype=DPType.ENUM, prefer_function=True, ): diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 632d05ce931..ab2d28ef645 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -148,6 +148,11 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "wk_air_conditioner": [ + # https://github.com/home-assistant/core/issues/146263 + Platform.CLIMATE, + Platform.SWITCH, + ], "ydkt_dolceclima_unsupported": [ # https://github.com/orgs/home-assistant/discussions/288 # unsupported device - no platforms diff --git a/tests/components/tuya/fixtures/wk_air_conditioner.json b/tests/components/tuya/fixtures/wk_air_conditioner.json new file mode 100644 index 00000000000..2c162a1a514 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_air_conditioner.json @@ -0,0 +1,102 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1749538552551GHfV17", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf6fc1645146455a2efrex", + "name": "Clima cucina", + "category": "wk", + "product_id": "aqoouq7x", + "product_name": "T7-Air conditioner thermostat\uff08ZIGBEE)", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-04-21T13:39:47+00:00", + "create_time": "2025-04-21T13:39:47+00:00", + "update_time": "2025-04-21T13:39:47+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "mode": "cold", + "temp_set": 25, + "temp_current": 27, + "level": "auto", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 42fc10fef54..6e93a1b263c 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,87 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.clima_cucina', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf6fc1645146455a2efrex', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27.0, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'friendly_name': 'Clima cucina', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 25.0, + }), + 'context': , + 'entity_id': 'climate.clima_cucina', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index dc47486e980..92243414892 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1161,6 +1161,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.clima_cucina_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf6fc1645146455a2efrexchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clima cucina Child lock', + }), + 'context': , + 'entity_id': 'switch.clima_cucina_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From aa1314c1d549af03589b25ff468e119449e4c09e Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 28 Jul 2025 23:43:20 +0800 Subject: [PATCH 217/235] Add YoLink YS6614 support. (#149153) --- homeassistant/components/yolink/const.py | 2 ++ homeassistant/components/yolink/sensor.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 9556c1bbd82..851b65e1a15 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -32,6 +32,8 @@ DEV_MODEL_FLEX_FOB_YS3614_UC = "YS3614-UC" DEV_MODEL_FLEX_FOB_YS3614_EC = "YS3614-EC" DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC" DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC" +DEV_MODEL_PLUG_YS6614_UC = "YS6614-UC" +DEV_MODEL_PLUG_YS6614_EC = "YS6614-EC" DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC" DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC" DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 37cd763194d..5425c242821 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -58,6 +58,8 @@ from homeassistant.util import percentage from .const import ( DEV_MODEL_PLUG_YS6602_EC, DEV_MODEL_PLUG_YS6602_UC, + DEV_MODEL_PLUG_YS6614_EC, + DEV_MODEL_PLUG_YS6614_UC, DEV_MODEL_PLUG_YS6803_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_TH_SENSOR_YS8004_EC, @@ -152,6 +154,8 @@ NONE_HUMIDITY_SENSOR_MODELS = [ POWER_SUPPORT_MODELS = [ DEV_MODEL_PLUG_YS6602_UC, DEV_MODEL_PLUG_YS6602_EC, + DEV_MODEL_PLUG_YS6614_UC, + DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_PLUG_YS6803_EC, ] @@ -319,6 +323,15 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SOIL_TH_SENSOR], should_update_entity=lambda value: value is not None, ), + YoLinkSensorEntityDescription( + key="coreTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + exists_fn=lambda device: device.device_model_name + in [DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6614_UC], + should_update_entity=lambda value: value is not None, + ), ) From 8339516fb4eb829a1b55bcb81d06ecea7250fa09 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:44:43 -0400 Subject: [PATCH 218/235] Add optimistic option to alarm control panel yaml (#149334) --- .../template/alarm_control_panel.py | 16 +++----- .../template/test_alarm_control_panel.py | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index f95fc0dbab7..9bcb656e4aa 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -49,6 +49,7 @@ from .helpers import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -113,8 +114,8 @@ ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema( ) ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( - make_template_entity_common_modern_schema(DEFAULT_NAME).schema -) + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( { @@ -205,13 +206,12 @@ class AbstractTemplateAlarmControlPanel( """Representation of a templated Alarm Control Panel features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) - self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value @@ -273,18 +273,14 @@ class AbstractTemplateAlarmControlPanel( async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any): """Arm the panel to specified state with supplied script.""" - optimistic_set = False - - if self._template is None: - self._state = state - optimistic_set = True if script: await self.async_run_script( script, run_variables={ATTR_CODE: code}, context=self._context ) - if optimistic_set: + if self._attr_assumed_state: + self._state = state self.async_write_ha_state() async def async_alarm_arm_away(self, code: str | None = None) -> None: diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 06d678edcab..c1df654e328 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -932,3 +932,44 @@ async def test_flow_preview( ) assert state["state"] == AlarmControlPanelState.DISARMED + + +@pytest.mark.parametrize( + ("count", "panel_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('alarm_control_panel.test') }}", + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_panel") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.DISARMED) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + "alarm_arm_away", + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_AWAY + + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.ARMED_HOME) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_HOME From 5af4290b7753831f895c9da13cca5c0143421ea9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 28 Jul 2025 19:33:39 +0300 Subject: [PATCH 219/235] Update IQS for Alexa Devices (#149440) --- homeassistant/components/alexa_devices/quality_scale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 6b1d084b842..47ff53dd04e 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -51,14 +51,14 @@ rules: docs-known-limitations: todo docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: From b1dd742a57b3eb52788b660a907821efa5b243ab Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 18:49:12 +0200 Subject: [PATCH 220/235] Move battery properties from legacy Ecovacs vacuum entity to separate entities (#149084) --- .../components/ecovacs/binary_sensor.py | 51 +++++++++++++++++- homeassistant/components/ecovacs/sensor.py | 50 +++++++++++++++++- homeassistant/components/ecovacs/vacuum.py | 24 +-------- .../ecovacs/snapshots/test_sensor.ambr | 52 +++++++++++++++++++ tests/components/ecovacs/test_init.py | 2 +- 5 files changed, 151 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 32bf5d3ba15..5997559c3cf 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -4,10 +4,12 @@ from collections.abc import Callable from dataclasses import dataclass from deebot_client.capabilities import CapabilityEvent -from deebot_client.events.base import Event +from deebot_client.events import Event from deebot_client.events.water_info import MopAttachedEvent +from sucks import VacBot from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -16,7 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsLegacyEntity, +) from .util import get_supported_entities @@ -47,12 +53,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" + controller = config_entry.runtime_data + async_add_entities( get_supported_entities( config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS ) ) + legacy_entities = [] + for device in controller.legacy_devices: + if not controller.legacy_entity_is_added(device, "battery_charging"): + controller.add_legacy_entity(device, "battery_charging") + legacy_entities.append(EcovacsLegacyBatteryChargingSensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) + class EcovacsBinarySensor[EventT: Event]( EcovacsDescriptionEntity[CapabilityEvent[EventT]], @@ -71,3 +88,33 @@ class EcovacsBinarySensor[EventT: Event]( self.async_write_ha_state() self._subscribe(self._capability.event, on_event) + + +class EcovacsLegacyBatteryChargingSensor(EcovacsLegacyEntity, BinarySensorEntity): + """Legacy battery charging sensor.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_charging" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.statusEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if self.device.charge_status is None: + return None + return bool(self.device.is_charging) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index e84485228e4..b368b92a579 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -37,6 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import StateType from . import EcovacsConfigEntry @@ -225,7 +226,7 @@ async def async_setup_entry( async_add_entities(entities) - async def _add_legacy_entities() -> None: + async def _add_legacy_lifespan_entities() -> None: entities = [] for device in controller.legacy_devices: for description in LEGACY_LIFESPAN_SENSORS: @@ -242,14 +243,21 @@ async def async_setup_entry( async_add_entities(entities) def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None: - hass.create_task(_add_legacy_entities()) + hass.create_task(_add_legacy_lifespan_entities()) + legacy_entities = [] for device in controller.legacy_devices: config_entry.async_on_unload( device.lifespanEvents.subscribe( _fire_ecovacs_legacy_lifespan_event ).unsubscribe ) + if not controller.legacy_entity_is_added(device, "battery_status"): + controller.add_legacy_entity(device, "battery_status") + legacy_entities.append(EcovacsLegacyBatterySensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) class EcovacsSensor( @@ -344,6 +352,44 @@ class EcovacsErrorSensor( self._subscribe(self._capability.event, on_event) +class EcovacsLegacyBatterySensor(EcovacsLegacyEntity, SensorEntity): + """Legacy battery sensor.""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = SensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_status" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.batteryEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if (status := self.device.battery_status) is not None: + return status * 100 # type: ignore[no-any-return] + return None + + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + return icon_for_battery_level( + battery_level=self.native_value, charging=self.device.is_charging + ) + + class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity): """Legacy Lifespan sensor.""" diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 6570b80e920..d432410c8c5 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify from . import EcovacsConfigEntry @@ -71,8 +70,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -89,11 +87,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): lambda _: self.schedule_update_ha_state() ) ) - self._event_listeners.append( - self.device.batteryEvents.subscribe( - lambda _: self.schedule_update_ha_state() - ) - ) self._event_listeners.append( self.device.lifespanEvents.subscribe( lambda _: self.schedule_update_ha_state() @@ -137,21 +130,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): return None - @property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - if self.device.battery_status is not None: - return self.device.battery_status * 100 # type: ignore[no-any-return] - - return None - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return icon_for_battery_level( - battery_level=self.battery_level, charging=self.device.is_charging - ) - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index fcd043e10fa..c216c4c9e4a 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.e1234567890000000003_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'E1234567890000000003_battery_status', + 'unit_of_measurement': '%', + }) +# --- +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'E1234567890000000003 Battery', + 'icon': 'mdi:battery-unknown', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.e1234567890000000003_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_legacy_sensors[123][sensor.e1234567890000000003_filter_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -148,6 +199,7 @@ # --- # name: test_legacy_sensors[123][states] list([ + 'sensor.e1234567890000000003_battery', 'sensor.e1234567890000000003_main_brush_lifespan', 'sensor.e1234567890000000003_side_brush_lifespan', 'sensor.e1234567890000000003_filter_lifespan', diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index c0e5ce143c9..3115f1b4040 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -107,7 +107,7 @@ async def test_devices_in_dr( [ ("yna5x1", 26), ("5xu9h3", 25), - ("123", 1), + ("123", 2), ], ) async def test_all_entities_loaded( From dda46e7e0bddcd46d2231bffedc89a128ab94c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 28 Jul 2025 18:38:06 +0100 Subject: [PATCH 221/235] Use non-autospec mock in Reolink's remaining tests (#149565) Co-authored-by: starkillerOG --- tests/components/reolink/conftest.py | 65 +++-------- tests/components/reolink/test_config_flow.py | 114 ++++++++----------- tests/components/reolink/test_select.py | 49 ++++---- tests/components/reolink/test_update.py | 50 ++++---- 4 files changed, 102 insertions(+), 176 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d699d1b9102..fa4cac6fff3 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -1,11 +1,10 @@ """Setup the Reolink tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.api import Chime -from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -91,6 +90,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.expire_session = AsyncMock() host_mock.set_volume = AsyncMock() host_mock.set_hub_audio = AsyncMock() + host_mock.play_quick_reply = AsyncMock() + host_mock.update_firmware = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -155,6 +156,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.recording_packing_time = "60 Minutes" # Baichuan + host_mock.baichuan = MagicMock() host_mock.baichuan_only = False # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT @@ -163,6 +165,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.unsubscribe_events = AsyncMock() host_mock.baichuan.check_subscribe_events = AsyncMock() host_mock.baichuan.get_privacy_mode = AsyncMock() + host_mock.baichuan.set_privacy_mode = AsyncMock() + host_mock.baichuan.set_scene = AsyncMock() host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" @@ -180,38 +184,20 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_name.return_value = "zone1" -@pytest.fixture(scope="module") -def reolink_connect_class() -> Generator[MagicMock]: +@pytest.fixture +def reolink_host_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" - with ( - patch( - "homeassistant.components.reolink.host.Host", autospec=True - ) as host_mock_class, - ): - host_mock = host_mock_class.return_value - host_mock.baichuan = create_autospec(Baichuan) - _init_host_mock(host_mock) + with patch( + "homeassistant.components.reolink.host.Host", autospec=False + ) as host_mock_class: + _init_host_mock(host_mock_class.return_value) yield host_mock_class @pytest.fixture -def reolink_connect( - reolink_connect_class: MagicMock, -) -> Generator[MagicMock]: - """Mock reolink connection.""" - return reolink_connect_class.return_value - - -@pytest.fixture -def reolink_host() -> Generator[MagicMock]: +def reolink_host(reolink_host_class: MagicMock) -> Generator[MagicMock]: """Mock reolink Host class.""" - with patch( - "homeassistant.components.reolink.host.Host", autospec=False - ) as host_mock_class: - host_mock = host_mock_class.return_value - host_mock.baichuan = MagicMock() - _init_host_mock(host_mock) - yield host_mock + return reolink_host_class.return_value @pytest.fixture @@ -246,29 +232,6 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry -@pytest.fixture -def test_chime(reolink_connect: MagicMock) -> None: - """Mock a reolink chime.""" - TEST_CHIME = Chime( - host=reolink_connect, - dev_id=12345678, - channel=0, - ) - TEST_CHIME.name = "Test chime" - TEST_CHIME.volume = 3 - TEST_CHIME.connect_state = 2 - TEST_CHIME.led_state = True - TEST_CHIME.event_info = { - "md": {"switch": 0, "musicId": 0}, - "people": {"switch": 0, "musicId": 1}, - "visitor": {"switch": 1, "musicId": 2}, - } - - reolink_connect.chime_list = [TEST_CHIME] - reolink_connect.chime.return_value = TEST_CHIME - return TEST_CHIME - - @pytest.fixture def reolink_chime(reolink_host: MagicMock) -> None: """Mock a reolink chime.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4b116929ac8..0a837a97b20 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -58,7 +58,7 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures("reolink_connect") +pytestmark = pytest.mark.usefixtures("reolink_host") async def test_config_flow_manual_success( @@ -101,11 +101,11 @@ async def test_config_flow_manual_success( async def test_config_flow_privacy_success( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow when privacy mode is turned on.""" - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.get_host_data.side_effect = LoginPrivacyModeError("Test error") + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.get_host_data.side_effect = LoginPrivacyModeError("Test error") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -128,13 +128,13 @@ async def test_config_flow_privacy_success( assert result["step_id"] == "privacy" assert result["errors"] is None - assert reolink_connect.baichuan.set_privacy_mode.call_count == 0 - reolink_connect.get_host_data.reset_mock(side_effect=True) + assert reolink_host.baichuan.set_privacy_mode.call_count == 0 + reolink_host.get_host_data.reset_mock(side_effect=True) with patch("homeassistant.components.reolink.config_flow.API_STARTUP_TIME", new=0): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert reolink_connect.baichuan.set_privacy_mode.call_count == 1 + assert reolink_host.baichuan.set_privacy_mode.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_NVR_NAME @@ -153,14 +153,12 @@ async def test_config_flow_privacy_success( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan.privacy_mode.return_value = False - async def test_config_flow_baichuan_only( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user for baichuan only device.""" - reolink_connect.baichuan_only = True + reolink_host.baichuan_only = True result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -196,11 +194,9 @@ async def test_config_flow_baichuan_only( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan_only = False - async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -211,10 +207,10 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {} - reolink_connect.is_admin = False - reolink_connect.user_level = "guest" - reolink_connect.unsubscribe.side_effect = ReolinkError("Test error") - reolink_connect.logout.side_effect = ReolinkError("Test error") + reolink_host.is_admin = False + reolink_host.user_level = "guest" + reolink_host.unsubscribe.side_effect = ReolinkError("Test error") + reolink_host.logout.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -228,9 +224,9 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_USERNAME: "not_admin"} - reolink_connect.is_admin = True - reolink_connect.user_level = "admin" - reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + reolink_host.is_admin = True + reolink_host.user_level = "admin" + reolink_host.get_host_data.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -244,7 +240,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "cannot_connect"} - reolink_connect.get_host_data.side_effect = ReolinkWebhookException("Test error") + reolink_host.get_host_data.side_effect = ReolinkWebhookException("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -258,7 +254,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "webhook_exception"} - reolink_connect.get_host_data.side_effect = json.JSONDecodeError( + reolink_host.get_host_data.side_effect = json.JSONDecodeError( "test_error", "test", 1 ) result = await hass.config_entries.flow.async_configure( @@ -274,7 +270,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "unknown"} - reolink_connect.get_host_data.side_effect = CredentialsInvalidError("Test error") + reolink_host.get_host_data.side_effect = CredentialsInvalidError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -288,7 +284,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} - reolink_connect.get_host_data.side_effect = LoginFirmwareError("Test error") + reolink_host.get_host_data.side_effect = LoginFirmwareError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -302,7 +298,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "update_needed"} - reolink_connect.valid_password.return_value = False + reolink_host.valid_password.return_value = False result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -316,8 +312,8 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "password_incompatible"} - reolink_connect.valid_password.return_value = True - reolink_connect.get_host_data.side_effect = ApiError("Test error") + reolink_host.valid_password.return_value = True + reolink_host.get_host_data.side_effect = ApiError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -331,7 +327,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "api_error"} - reolink_connect.get_host_data.reset_mock(side_effect=True) + reolink_host.get_host_data.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -360,9 +356,6 @@ async def test_config_flow_errors( CONF_PROTOCOL: DEFAULT_PROTOCOL, } - reolink_connect.unsubscribe.reset_mock(side_effect=True) - reolink_connect.logout.reset_mock(side_effect=True) - async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" @@ -450,7 +443,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: async def test_reauth_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( @@ -475,7 +468,7 @@ async def test_reauth_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reauth_flow(hass) @@ -497,8 +490,6 @@ async def test_reauth_abort_unique_id_mismatch( assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - reolink_connect.mac_address = TEST_MAC - async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" @@ -544,8 +535,8 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No async def test_dhcp_ip_update_aborted_if_wrong_mac( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery does not update the IP if the mac address does not match.""" config_entry = MockConfigEntry( @@ -572,7 +563,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -583,7 +574,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( macaddress=DHCP_FORMATTED_MAC, ) - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -602,9 +593,9 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in [TEST_HOST, TEST_HOST2] get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -616,10 +607,6 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( # Check that IP was not updated assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - reolink_connect.mac_address = TEST_MAC - @pytest.mark.parametrize( ("attr", "value", "expected", "host_call_list"), @@ -641,8 +628,8 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( async def test_dhcp_ip_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, attr: str, value: Any, expected: str, @@ -673,7 +660,7 @@ async def test_dhcp_ip_update( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -685,8 +672,7 @@ async def test_dhcp_ip_update( ) if attr is not None: - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -705,9 +691,9 @@ async def test_dhcp_ip_update( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in host_call_list get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -718,17 +704,12 @@ async def test_dhcp_ip_update( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == expected - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - if attr is not None: - setattr(reolink_connect, attr, original) - async def test_dhcp_ip_update_ingnored_if_still_connected( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery is ignored when the camera is still properly connected to HA.""" config_entry = MockConfigEntry( @@ -776,9 +757,9 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] == TEST_HOST get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -789,9 +770,6 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reconfiguration flow.""" @@ -840,7 +818,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non async def test_reconfig_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reconfiguration flow aborts if the unique id does not match.""" config_entry = MockConfigEntry( @@ -865,7 +843,7 @@ async def test_reconfig_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reconfigure_flow(hass) @@ -887,5 +865,3 @@ async def test_reconfig_abort_unique_id_mismatch( assert config_entry.data[CONF_HOST] == TEST_HOST assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - - reolink_connect.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 32bc5e4435e..fb0f98a6e31 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -29,7 +29,7 @@ async def test_floodlight_mode_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select entity with floodlight_mode.""" @@ -47,9 +47,9 @@ async def test_floodlight_mode_select( {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - reolink_connect.set_whiteled.assert_called_once() + reolink_host.set_whiteled.assert_called_once() - reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + reolink_host.set_whiteled.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -58,7 +58,7 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.set_whiteled.side_effect = InvalidParameterError("Test error") + reolink_host.set_whiteled.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -67,24 +67,22 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.whiteled_mode.return_value = -99 # invalid value + reolink_host.whiteled_mode.return_value = -99 # invalid value freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_play_quick_reply_message( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select play_quick_reply_message entity.""" - reolink_connect.quick_reply_dict.return_value = {0: "off", 1: "test message"} + reolink_host.quick_reply_dict.return_value = {0: "off", 1: "test message"} with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -99,16 +97,14 @@ async def test_play_quick_reply_message( {ATTR_ENTITY_ID: entity_id, "option": "test message"}, blocking=True, ) - reolink_connect.play_quick_reply.assert_called_once() - - reolink_connect.quick_reply_dict = MagicMock() + reolink_host.play_quick_reply.assert_called_once() async def test_host_scene_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host select entity with scene mode.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): @@ -125,9 +121,9 @@ async def test_host_scene_select( {ATTR_ENTITY_ID: entity_id, "option": "home"}, blocking=True, ) - reolink_connect.baichuan.set_scene.assert_called_once() + reolink_host.baichuan.set_scene.assert_called_once() - reolink_connect.baichuan.set_scene.side_effect = ReolinkError("Test error") + reolink_host.baichuan.set_scene.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -136,7 +132,7 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.set_scene.side_effect = InvalidParameterError("Test error") + reolink_host.baichuan.set_scene.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -145,23 +141,20 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.active_scene = "Invalid value" + reolink_host.baichuan.active_scene = "Invalid value" freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.baichuan.set_scene.reset_mock(side_effect=True) - reolink_connect.baichuan.active_scene = "off" - async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime select entity.""" @@ -175,16 +168,16 @@ async def test_chime_select( assert hass.states.get(entity_id).state == "pianokey" # Test selecting chime ringtone option - test_chime.set_tone = AsyncMock() + reolink_chime.set_tone = AsyncMock() await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - test_chime.set_tone.assert_called_once() + reolink_chime.set_tone.assert_called_once() - test_chime.set_tone.side_effect = ReolinkError("Test error") + reolink_chime.set_tone.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -193,7 +186,7 @@ async def test_chime_select( blocking=True, ) - test_chime.set_tone.side_effect = InvalidParameterError("Test error") + reolink_chime.set_tone.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -203,11 +196,9 @@ async def test_chime_select( ) # Test unavailable - test_chime.event_info = {} + reolink_chime.event_info = {} freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - - test_chime.set_tone.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index d48362516b8..d12b229e932 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -30,11 +30,11 @@ TEST_RELEASE_NOTES = "bugfix 1, bugfix 2" async def test_no_update( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when no update available.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_host.camera_name.return_value = TEST_CAM_NAME with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -49,12 +49,12 @@ async def test_no_update( async def test_update_str( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when update available with string from API.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.firmware_update_available.return_value = "New firmware available" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.firmware_update_available.return_value = "New firmware available" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -69,21 +69,21 @@ async def test_update_str( async def test_update_firm( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, entity_name: str, ) -> None: """Test update state when update available with firmware info from reolink.com.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.sw_upload_progress.return_value = 100 - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.sw_upload_progress.return_value = 100 + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -117,9 +117,9 @@ async def test_update_firm( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.update_firmware.assert_called() + reolink_host.update_firmware.assert_called() - reolink_connect.sw_upload_progress.return_value = 50 + reolink_host.sw_upload_progress.return_value = 50 freezer.tick(POLL_PROGRESS) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -127,7 +127,7 @@ async def test_update_firm( assert hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] == 50 - reolink_connect.sw_upload_progress.return_value = 100 + reolink_host.sw_upload_progress.return_value = 100 freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -135,7 +135,7 @@ async def test_update_firm( assert not hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] is None - reolink_connect.update_firmware.side_effect = ReolinkError("Test error") + reolink_host.update_firmware.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( UPDATE_DOMAIN, @@ -144,7 +144,7 @@ async def test_update_firm( blocking=True, ) - reolink_connect.update_firmware.side_effect = ApiError( + reolink_host.update_firmware.side_effect = ApiError( "Test error", translation_key="firmware_rate_limit" ) with pytest.raises(HomeAssistantError): @@ -156,34 +156,32 @@ async def test_update_firm( ) # test _async_update_future - reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" - reolink_connect.firmware_update_available.return_value = False + reolink_host.camera_sw_version.return_value = "v3.3.0.226_23031644" + reolink_host.firmware_update_available.return_value = False freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF - reolink_connect.update_firmware.side_effect = None - @pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) async def test_update_firm_keeps_available( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, entity_name: str, ) -> None: """Test update entity keeps being available during update.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -196,7 +194,7 @@ async def test_update_firm_keeps_available( async def mock_update_firmware(*args, **kwargs) -> None: await asyncio.sleep(0.000005) - reolink_connect.update_firmware = mock_update_firmware + reolink_host.update_firmware = mock_update_firmware # test install with patch("homeassistant.components.reolink.update.POLL_PROGRESS", 0.000001): @@ -207,11 +205,9 @@ async def test_update_firm_keeps_available( blocking=True, ) - reolink_connect.session_active = False + reolink_host.session_active = False async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() # still available assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.session_active = True From 7f9be420d2180cd44a5e08bec26bdf37e70c1239 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 28 Jul 2025 20:54:54 +0200 Subject: [PATCH 222/235] Add details to Husqvarna Automower restricted reason sensor (#147678) Co-authored-by: Norbert Rittel --- .../components/husqvarna_automower/sensor.py | 29 ++++++++++- .../husqvarna_automower/strings.json | 26 +++++++--- .../snapshots/test_sensor.ambr | 48 +++++++++++++++++++ .../husqvarna_automower/test_sensor.py | 43 ++++++++++++++++- 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 0ff72271cb9..7f2921f17fa 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -8,6 +8,7 @@ from operator import attrgetter from typing import TYPE_CHECKING, Any from aioautomower.model import ( + ExternalReasons, InactiveReasons, MowerAttributes, MowerModes, @@ -190,11 +191,37 @@ RESTRICTED_REASONS: list = [ RestrictedReasons.PARK_OVERRIDE, RestrictedReasons.SENSOR, RestrictedReasons.WEEK_SCHEDULE, + ExternalReasons.AMAZON_ALEXA, + ExternalReasons.DEVELOPER_PORTAL, + ExternalReasons.GARDENA_SMART_SYSTEM, + ExternalReasons.GOOGLE_ASSISTANT, + ExternalReasons.HOME_ASSISTANT, + ExternalReasons.IFTTT, + ExternalReasons.IFTTT_APPLETS, + ExternalReasons.IFTTT_CALENDAR_CONNECTION, + ExternalReasons.SMART_ROUTINE, + ExternalReasons.SMART_ROUTINE_FROST_GUARD, + ExternalReasons.SMART_ROUTINE_RAIN_GUARD, + ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION, ] STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active" +@callback +def _get_restricted_reason(data: MowerAttributes) -> str: + """Return the restricted reason. + + If there is an external reason, return that instead, if it's available. + """ + if ( + data.planner.restricted_reason == RestrictedReasons.EXTERNAL + and data.planner.external_reason is not None + ): + return data.planner.external_reason + return data.planner.restricted_reason + + @callback def _get_work_area_names(data: MowerAttributes) -> list[str]: """Return a list with all work area names.""" @@ -400,7 +427,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( translation_key="restricted_reason", device_class=SensorDeviceClass.ENUM, option_fn=lambda data: RESTRICTED_REASONS, - value_fn=attrgetter("planner.restricted_reason"), + value_fn=_get_restricted_reason, ), AutomowerSensorEntityDescription( key="inactive_reason", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 62843d67ae2..226c9ee17f0 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -242,16 +242,28 @@ "restricted_reason": { "name": "Restricted reason", "state": { - "none": "No restrictions", - "week_schedule": "Week schedule", - "park_override": "Park override", - "sensor": "Weather timer", + "all_work_areas_completed": "All work areas completed", + "amazon_alexa": "Amazon Alexa", "daily_limit": "Daily limit", + "developer_portal": "Developer Portal", + "external": "External", "fota": "Firmware Over-the-Air update running", "frost": "Frost", - "all_work_areas_completed": "All work areas completed", - "external": "External", - "not_applicable": "Not applicable" + "gardena_smart_system": "Gardena Smart System", + "google_assistant": "Google Assistant", + "home_assistant": "Home Assistant", + "ifttt_applets": "IFTTT applets", + "ifttt_calendar_connection": "IFTTT calendar connection", + "ifttt": "IFTTT", + "none": "No restrictions", + "not_applicable": "Not applicable", + "park_override": "Park override", + "sensor": "Weather timer", + "smart_routine_frost_guard": "Frost guard", + "smart_routine_rain_guard": "Rain guard", + "smart_routine_wildlife_protection": "Wildlife protection", + "smart_routine": "Generic smart routine", + "week_schedule": "Week schedule" } }, "total_charging_time": { diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 0fe46c24254..3aa3504cc26 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -978,6 +978,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -1025,6 +1037,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , @@ -1953,6 +1977,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -2000,6 +2036,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index d756b1b2ffa..204fba872c4 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -4,7 +4,13 @@ import datetime from unittest.mock import AsyncMock, patch import zoneinfo -from aioautomower.model import MowerAttributes, MowerModes, MowerStates +from aioautomower.model import ( + ExternalReasons, + MowerAttributes, + MowerModes, + MowerStates, + RestrictedReasons, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -123,6 +129,41 @@ async def test_work_area_sensor( assert state.state == "no_work_area_active" +async def test_restricted_reason_sensor( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], +) -> None: + """Test the work area sensor.""" + sensor = "sensor.test_mower_1_restricted_reason" + await setup_integration(hass, mock_config_entry) + state = hass.states.get(sensor) + assert state is not None + assert state.state == RestrictedReasons.WEEK_SCHEDULE + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[TEST_MOWER_ID].planner.external_reason = None + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == RestrictedReasons.EXTERNAL + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[ + TEST_MOWER_ID + ].planner.external_reason = ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("sensor_to_test"), From cf05f1046d9c1617eff533bcb65c5af3f4bce6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Mon, 28 Jul 2025 22:19:51 +0200 Subject: [PATCH 223/235] Add action to retrieve list of programs on miele appliance (#149307) --- homeassistant/components/miele/icons.json | 3 + homeassistant/components/miele/services.py | 108 ++++++++++++++-- homeassistant/components/miele/services.yaml | 8 ++ homeassistant/components/miele/strings.json | 15 ++- tests/components/miele/conftest.py | 6 +- tests/components/miele/fixtures/programs.json | 34 +++++ .../fixtures/programs_washing_machine.json | 117 ------------------ .../miele/snapshots/test_services.ambr | 48 +++++++ tests/components/miele/test_services.py | 54 +++++++- 9 files changed, 262 insertions(+), 131 deletions(-) create mode 100644 tests/components/miele/fixtures/programs.json delete mode 100644 tests/components/miele/fixtures/programs_washing_machine.json create mode 100644 tests/components/miele/snapshots/test_services.ambr diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 1b757a9e113..4a0eac7da85 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -105,6 +105,9 @@ } }, "services": { + "get_programs": { + "service": "mdi:stack-overflow" + }, "set_program": { "service": "mdi:arrow-right-circle-outline" } diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py index 70ea20ccc4a..6d4dc77dd36 100644 --- a/homeassistant/components/miele/services.py +++ b/homeassistant/components/miele/services.py @@ -7,7 +7,12 @@ import aiohttp import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.service import async_extract_config_entry_ids @@ -27,6 +32,13 @@ SERVICE_SET_PROGRAM_SCHEMA = vol.Schema( }, ) +SERVICE_GET_PROGRAMS = "get_programs" +SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + }, +) + _LOGGER = logging.getLogger(__name__) @@ -47,17 +59,12 @@ async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry: return target_entries[0] -async def set_program(call: ServiceCall) -> None: - """Set a program on a Miele appliance.""" +async def _get_serial_number(call: ServiceCall) -> str: + """Extract the serial number from the device identifier.""" - _LOGGER.debug("Set program call: %s", call) - config_entry = await _extract_config_entry(call) device_reg = dr.async_get(call.hass) - api = config_entry.runtime_data.api device = call.data[ATTR_DEVICE_ID] device_entry = device_reg.async_get(device) - - data = {"programId": call.data[ATTR_PROGRAM_ID]} serial_number = next( ( identifier[1] @@ -71,6 +78,18 @@ async def set_program(call: ServiceCall) -> None: translation_domain=DOMAIN, translation_key="invalid_target", ) + return serial_number + + +async def set_program(call: ServiceCall) -> None: + """Set a program on a Miele appliance.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + + serial_number = await _get_serial_number(call) + data = {"programId": call.data[ATTR_PROGRAM_ID]} try: await api.set_program(serial_number, data) except aiohttp.ClientResponseError as ex: @@ -84,9 +103,82 @@ async def set_program(call: ServiceCall) -> None: ) from ex +async def get_programs(call: ServiceCall) -> ServiceResponse: + """Get available programs from appliance.""" + + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + serial_number = await _get_serial_number(call) + + try: + programs = await api.get_programs(serial_number) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_programs_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + return { + "programs": [ + { + "program_id": item["programId"], + "program": item["program"], + "parameters": ( + { + "temperature": ( + { + "min": item["parameters"]["temperature"]["min"], + "max": item["parameters"]["temperature"]["max"], + "step": item["parameters"]["temperature"]["step"], + "mandatory": item["parameters"]["temperature"][ + "mandatory" + ], + } + if "temperature" in item["parameters"] + else {} + ), + "duration": ( + { + "min": { + "hours": item["parameters"]["duration"]["min"][0], + "minutes": item["parameters"]["duration"]["min"][1], + }, + "max": { + "hours": item["parameters"]["duration"]["max"][0], + "minutes": item["parameters"]["duration"]["max"][1], + }, + "mandatory": item["parameters"]["duration"][ + "mandatory" + ], + } + if "duration" in item["parameters"] + else {} + ), + } + if item["parameters"] + else {} + ), + } + for item in programs + ], + } + + async def async_setup_services(hass: HomeAssistant) -> None: """Set up services.""" hass.services.async_register( DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PROGRAMS, + get_programs, + SERVICE_GET_PROGRAMS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml index 486fdf7307b..6866e997c45 100644 --- a/homeassistant/components/miele/services.yaml +++ b/homeassistant/components/miele/services.yaml @@ -1,5 +1,13 @@ # Services descriptions for Miele integration +get_programs: + fields: + device_id: + selector: + device: + integration: miele + required: true + set_program: fields: device_id: diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 2ae412ed95e..5b5cac16b53 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1062,6 +1062,9 @@ "invalid_target": { "message": "Invalid device targeted." }, + "get_programs_error": { + "message": "'Get programs' action failed {status} / {message}." + }, "set_program_error": { "message": "'Set program' action failed {status} / {message}." }, @@ -1070,12 +1073,22 @@ } }, "services": { + "get_programs": { + "name": "Get programs", + "description": "Returns a list of available programs.", + "fields": { + "device_id": { + "description": "[%key:component::miele::services::set_program::fields::device_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::device_id::name%]" + } + } + }, "set_program": { "name": "Set program", "description": "Sets and starts a program on the appliance.", "fields": { "device_id": { - "description": "The device to set the program on.", + "description": "The target device for this action.", "name": "Device" }, "program_id": { diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 7b3c3f35f7e..d91485ffc59 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -20,8 +20,8 @@ from .const import CLIENT_ID, CLIENT_SECRET from tests.common import ( MockConfigEntry, - async_load_fixture, async_load_json_object_fixture, + load_json_value_fixture, ) @@ -99,13 +99,13 @@ async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAct @pytest.fixture(scope="package") def load_programs_file() -> str: """Fixture for loading programs file.""" - return "programs_washing_machine.json" + return "programs.json" @pytest.fixture async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]: """Fixture for available programs.""" - return await async_load_fixture(hass, load_programs_file, DOMAIN) + return load_json_value_fixture(load_programs_file, DOMAIN) @pytest.fixture diff --git a/tests/components/miele/fixtures/programs.json b/tests/components/miele/fixtures/programs.json new file mode 100644 index 00000000000..06eddc5fedc --- /dev/null +++ b/tests/components/miele/fixtures/programs.json @@ -0,0 +1,34 @@ +[ + { + "programId": 1, + "program": "Cottons", + "parameters": {} + }, + { + "programId": 146, + "program": "QuickPowerWash", + "parameters": {} + }, + { + "programId": 123, + "program": "Dark garments / Denim", + "parameters": {} + }, + { + "programId": 13, + "program": "Fan plus", + "parameters": { + "temperature": { + "min": 30, + "max": 250, + "step": 5, + "mandatory": false + }, + "duration": { + "min": [0, 1], + "max": [12, 0], + "mandatory": true + } + } + } +] diff --git a/tests/components/miele/fixtures/programs_washing_machine.json b/tests/components/miele/fixtures/programs_washing_machine.json deleted file mode 100644 index a3c16ece8e6..00000000000 --- a/tests/components/miele/fixtures/programs_washing_machine.json +++ /dev/null @@ -1,117 +0,0 @@ -[ - { - "programId": 146, - "program": "QuickPowerWash", - "parameters": {} - }, - { - "programId": 123, - "program": "Dark garments / Denim", - "parameters": {} - }, - { - "programId": 190, - "program": "ECO 40-60 ", - "parameters": {} - }, - { - "programId": 27, - "program": "Proofing", - "parameters": {} - }, - { - "programId": 23, - "program": "Shirts", - "parameters": {} - }, - { - "programId": 9, - "program": "Silks ", - "parameters": {} - }, - { - "programId": 8, - "program": "Woollens ", - "parameters": {} - }, - { - "programId": 4, - "program": "Delicates", - "parameters": {} - }, - { - "programId": 3, - "program": "Minimum iron", - "parameters": {} - }, - { - "programId": 1, - "program": "Cottons", - "parameters": {} - }, - { - "programId": 69, - "program": "Cottons hygiene", - "parameters": {} - }, - { - "programId": 37, - "program": "Outerwear", - "parameters": {} - }, - { - "programId": 122, - "program": "Express 20", - "parameters": {} - }, - { - "programId": 29, - "program": "Sportswear", - "parameters": {} - }, - { - "programId": 31, - "program": "Automatic plus", - "parameters": {} - }, - { - "programId": 39, - "program": "Pillows", - "parameters": {} - }, - { - "programId": 22, - "program": "Curtains", - "parameters": {} - }, - { - "programId": 129, - "program": "Down filled items", - "parameters": {} - }, - { - "programId": 53, - "program": "First wash", - "parameters": {} - }, - { - "programId": 95, - "program": "Down duvets", - "parameters": {} - }, - { - "programId": 52, - "program": "Separate rinse / Starch", - "parameters": {} - }, - { - "programId": 21, - "program": "Drain / Spin", - "parameters": {} - }, - { - "programId": 91, - "program": "Clean machine", - "parameters": {} - } -] diff --git a/tests/components/miele/snapshots/test_services.ambr b/tests/components/miele/snapshots/test_services.ambr new file mode 100644 index 00000000000..3095ec9b6fb --- /dev/null +++ b/tests/components/miele/snapshots/test_services.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_services_with_response + dict({ + 'programs': list([ + dict({ + 'parameters': dict({ + }), + 'program': 'Cottons', + 'program_id': 1, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'QuickPowerWash', + 'program_id': 146, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'Dark garments / Denim', + 'program_id': 123, + }), + dict({ + 'parameters': dict({ + 'duration': dict({ + 'mandatory': True, + 'max': dict({ + 'hours': 12, + 'minutes': 0, + }), + 'min': dict({ + 'hours': 0, + 'minutes': 1, + }), + }), + 'temperature': dict({ + 'mandatory': False, + 'max': 250, + 'min': 30, + 'step': 5, + }), + }), + 'program': 'Fan plus', + 'program_id': 13, + }), + ]), + }) +# --- diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py index 8b33c17d69f..2bf0e2deb9c 100644 --- a/tests/components/miele/test_services.py +++ b/tests/components/miele/test_services.py @@ -4,10 +4,15 @@ from unittest.mock import MagicMock from aiohttp import ClientResponseError import pytest +from syrupy.assertion import SnapshotAssertion from voluptuous import MultipleInvalid from homeassistant.components.miele.const import DOMAIN -from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM +from homeassistant.components.miele.services import ( + ATTR_PROGRAM_ID, + SERVICE_GET_PROGRAMS, + SERVICE_SET_PROGRAM, +) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -44,6 +49,28 @@ async def test_services( ) +async def test_services_with_response( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the custom services that returns a response are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + { + ATTR_DEVICE_ID: device.id, + }, + blocking=True, + return_response=True, + ) + + async def test_service_api_errors( hass: HomeAssistant, device_registry: DeviceRegistry, @@ -60,7 +87,7 @@ async def test_service_api_errors( await hass.services.async_call( DOMAIN, SERVICE_SET_PROGRAM, - {"device_id": device.id, ATTR_PROGRAM_ID: 1}, + {ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1}, blocking=True, ) mock_miele_client.set_program.assert_called_once_with( @@ -68,6 +95,29 @@ async def test_service_api_errors( ) +async def test_get_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.get_programs.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match="'Get programs' action failed"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + {ATTR_DEVICE_ID: device.id}, + blocking=True, + return_response=True, + ) + mock_miele_client.get_programs.assert_called_once() + + async def test_service_validation_errors( hass: HomeAssistant, device_registry: DeviceRegistry, From 596f6cd216ccf2df6bff0109ec0a20630684ce29 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:21:04 +0200 Subject: [PATCH 224/235] Add people and tags collections to Immich media source (#149340) --- .../components/immich/media_source.py | 152 ++++-- tests/components/immich/conftest.py | 104 +++- tests/components/immich/const.py | 129 +++++ tests/components/immich/test_media_source.py | 477 ++++++++++++------ 4 files changed, 665 insertions(+), 197 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index caf8264895b..008a807c0d2 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -5,6 +5,7 @@ from __future__ import annotations from logging import getLogger from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse +from aioimmich.assets.models import ImmichAsset from aioimmich.exceptions import ImmichError from homeassistant.components.http import HomeAssistantView @@ -83,6 +84,10 @@ class ImmichMediaSource(MediaSource): self, item: MediaSourceItem, entries: list[ConfigEntry] ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" + + # -------------------------------------------------------- + # root level, render immich instances + # -------------------------------------------------------- if not item.identifier: LOGGER.debug("Render all Immich instances") return [ @@ -97,6 +102,10 @@ class ImmichMediaSource(MediaSource): ) for entry in entries ] + + # -------------------------------------------------------- + # 1st level, render collections overview + # -------------------------------------------------------- identifier = ImmichMediaSourceIdentifier(item.identifier) entry: ImmichConfigEntry | None = ( self.hass.config_entries.async_entry_for_domain_unique_id( @@ -111,50 +120,127 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums", + identifier=f"{identifier.unique_id}|{collection}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title="albums", + title=collection, can_play=False, can_expand=True, ) + for collection in ("albums", "people", "tags") ] + # -------------------------------------------------------- + # 2nd level, render collection + # -------------------------------------------------------- if identifier.collection_id is None: - LOGGER.debug("Render all albums for %s", entry.title) + if identifier.collection == "albums": + LOGGER.debug("Render all albums for %s", entry.title) + try: + albums = await immich_api.albums.async_get_all_albums() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|albums|{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.album_name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", + ) + for album in albums + ] + + if identifier.collection == "tags": + LOGGER.debug("Render all tags for %s", entry.title) + try: + tags = await immich_api.tags.async_get_all_tags() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|tags|{tag.tag_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=tag.name, + can_play=False, + can_expand=True, + ) + for tag in tags + ] + + if identifier.collection == "people": + LOGGER.debug("Render all people for %s", entry.title) + try: + people = await immich_api.people.async_get_all_people() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|people|{person.person_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=person.name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{person.person_id}/person/image/jpg", + ) + for person in people + ] + + # -------------------------------------------------------- + # final level, render assets + # -------------------------------------------------------- + assert identifier.collection_id is not None + assets: list[ImmichAsset] = [] + if identifier.collection == "albums": + LOGGER.debug( + "Render all assets of album %s for %s", + identifier.collection_id, + entry.title, + ) try: - albums = await immich_api.albums.async_get_all_albums() + album_info = await immich_api.albums.async_get_album_info( + identifier.collection_id + ) + assets = album_info.assets except ImmichError: return [] - return [ - BrowseMediaSource( - domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums|{album.album_id}", - media_class=MediaClass.DIRECTORY, - media_content_type=MediaClass.IMAGE, - title=album.album_name, - can_play=False, - can_expand=True, - thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", - ) - for album in albums - ] - - LOGGER.debug( - "Render all assets of album %s for %s", - identifier.collection_id, - entry.title, - ) - try: - album_info = await immich_api.albums.async_get_album_info( - identifier.collection_id + elif identifier.collection == "tags": + LOGGER.debug( + "Render all assets with tag %s", + identifier.collection_id, ) - except ImmichError: - return [] + try: + assets = await immich_api.search.async_get_all_by_tag_ids( + [identifier.collection_id] + ) + except ImmichError: + return [] + + elif identifier.collection == "people": + LOGGER.debug( + "Render all assets for person %s", + identifier.collection_id, + ) + try: + assets = await immich_api.search.async_get_all_by_person_ids( + [identifier.collection_id] + ) + except ImmichError: + return [] ret: list[BrowseMediaSource] = [] - for asset in album_info.assets: + for asset in assets: if not (mime_type := asset.original_mime_type) or not mime_type.startswith( ("image/", "video/") ): @@ -173,7 +259,8 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}|albums|" + f"{identifier.unique_id}|" + f"{identifier.collection}|" f"{identifier.collection_id}|" f"{asset.asset_id}|" f"{asset.original_file_name}|" @@ -257,7 +344,10 @@ class ImmichMediaView(HomeAssistantView): # web response for images try: - image = await immich_api.assets.async_view_asset(asset_id, size) + if size == "person": + image = await immich_api.people.async_get_person_thumbnail(asset_id) + else: + image = await immich_api.assets.async_view_asset(asset_id, size) except ImmichError as exc: raise HTTPNotFound from exc return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 48e36e70386..adcbf14d97b 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -4,15 +4,25 @@ from collections.abc import AsyncGenerator, Generator from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers +from aioimmich import ( + ImmichAlbums, + ImmichAssests, + ImmichPeople, + ImmichSearch, + ImmichServer, + ImmichTags, + ImmichUsers, +) from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse from aioimmich.assets.models import ImmichAssetUploadResponse +from aioimmich.people.models import ImmichPerson from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, ImmichServerStorage, ImmichServerVersionCheck, ) +from aioimmich.tags.models import ImmichTag from aioimmich.users.models import ImmichUserObject import pytest @@ -29,7 +39,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReaderChunked -from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS +from .const import ( + MOCK_ALBUM_WITH_ASSETS, + MOCK_ALBUM_WITHOUT_ASSETS, + MOCK_PEOPLE_ASSETS, + MOCK_TAGS_ASSETS, +) from tests.common import MockConfigEntry @@ -87,6 +102,58 @@ def mock_immich_assets() -> AsyncMock: return mock +@pytest.fixture +def mock_immich_people() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichPeople) + mock.async_get_all_people.return_value = [ + ImmichPerson.from_dict( + { + "id": "6176838a-ac5a-4d1f-9a35-91c591d962d8", + "name": "Me", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/61/76/6176838a-ac5a-4d1f-9a35-91c591d962d8.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-11T11:07:41.651Z", + } + ), + ImmichPerson.from_dict( + { + "id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f", + "name": "I", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/3e/66/3e66aa4a-a4a8-41a4-86fe-2ae5e490078f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-19T22:10:21.953Z", + } + ), + ImmichPerson.from_dict( + { + "id": "a3c83297-684a-4576-82dc-b07432e8a18f", + "name": "Myself", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/a3/c8/a3c83297-684a-4576-82dc-b07432e8a18f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-12T21:07:04.044Z", + } + ), + ] + mock.async_get_person_thumbnail.return_value = b"yyyy" + return mock + + +@pytest.fixture +def mock_immich_search() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichSearch) + mock.async_get_all_by_person_ids.return_value = MOCK_PEOPLE_ASSETS + mock.async_get_all_by_tag_ids.return_value = MOCK_TAGS_ASSETS + return mock + + @pytest.fixture def mock_immich_server() -> AsyncMock: """Mock the Immich server.""" @@ -153,6 +220,33 @@ def mock_immich_server() -> AsyncMock: return mock +@pytest.fixture +def mock_immich_tags() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichTags) + mock.async_get_all_tags.return_value = [ + ImmichTag.from_dict( + { + "id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + "name": "Halloween", + "value": "Halloween", + "createdAt": "2025-05-12T20:00:45.220Z", + "updatedAt": "2025-05-12T20:00:47.224Z", + }, + ), + ImmichTag.from_dict( + { + "id": "69bd487f-dc1e-4420-94c6-656f0515773d", + "name": "Holidays", + "value": "Holidays", + "createdAt": "2025-05-12T20:00:49.967Z", + "updatedAt": "2025-05-12T20:00:55.575Z", + }, + ), + ] + return mock + + @pytest.fixture def mock_immich_user() -> AsyncMock: """Mock the Immich server.""" @@ -185,7 +279,10 @@ def mock_immich_user() -> AsyncMock: async def mock_immich( mock_immich_albums: AsyncMock, mock_immich_assets: AsyncMock, + mock_immich_people: AsyncMock, + mock_immich_search: AsyncMock, mock_immich_server: AsyncMock, + mock_immich_tags: AsyncMock, mock_immich_user: AsyncMock, ) -> AsyncGenerator[AsyncMock]: """Mock the Immich API.""" @@ -196,7 +293,10 @@ async def mock_immich( client = mock_immich.return_value client.albums = mock_immich_albums client.assets = mock_immich_assets + client.people = mock_immich_people + client.search = mock_immich_search client.server = mock_immich_server + client.tags = mock_immich_tags client.users = mock_immich_user yield client diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index 97721bc7dbc..af718c4b754 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -1,6 +1,7 @@ """Constants for the Immich integration tests.""" from aioimmich.albums.models import ImmichAlbum +from aioimmich.assets.models import ImmichAsset from homeassistant.const import ( CONF_API_KEY, @@ -113,3 +114,131 @@ MOCK_ALBUM_WITH_ASSETS = ImmichAlbum.from_dict( ], } ) + +MOCK_PEOPLE_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "deviceAssetId": "1000092019", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/8e/a3/8ea31ee8-49c3-4be9-aa9d-b8ef26ba0abe.jpg", + "originalFileName": "20250714_201122.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILGeMlPaJaMWIeagJcJSA==", + "fileCreatedAt": "2025-07-14T18:11:22.648Z", + "fileModifiedAt": "2025-07-14T18:11:25.000Z", + "localDateTime": "2025-07-14T20:11:22.648Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "GcBJkDFoXx9d/wyl1xH89R4/NBQ=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + } + ), + ImmichAsset.from_dict( + { + "id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "deviceAssetId": "1000092018", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/f5/b4/f5b4b200-47dd-45e8-98a4-4128df3f9189.jpg", + "originalFileName": "20250714_201121.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILHeMlPeJaMSJmKgJcIWQ==", + "fileCreatedAt": "2025-07-14T18:11:21.582Z", + "fileModifiedAt": "2025-07-14T18:11:24.000Z", + "localDateTime": "2025-07-14T20:11:21.582Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "X6kMpPulu/HJQnKmTqCoQYl3Sjc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] + +MOCK_TAGS_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "deviceAssetId": "2132393", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/07/d0/07d04d86-7188-4335-95ca-9bd9fd2b399d.JPG", + "originalFileName": "20110306_025024.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "WCgSFYRXaYdQiYineIiHd4SghQUY", + "fileCreatedAt": "2011-03-06T01:50:24.000Z", + "fileModifiedAt": "2011-03-06T01:50:24.000Z", + "localDateTime": "2011-03-06T02:50:24.000Z", + "updatedAt": "2025-07-26T10:16:39.477Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "eNwN0AN2hEYZJJkonl7ylGzJzko=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), + ImmichAsset.from_dict( + { + "id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "deviceAssetId": "2142137", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/4a/f4/4af42484-86f8-47a0-958a-f32da89ee03a.JPG", + "originalFileName": "20110306_024053.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "4AcKFYZPZnhSmGl5daaYeG859ytT", + "fileCreatedAt": "2011-03-06T01:40:53.000Z", + "fileModifiedAt": "2011-03-06T01:40:52.000Z", + "localDateTime": "2011-03-06T02:40:53.000Z", + "updatedAt": "2025-07-26T10:16:39.474Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "VtokCjIwKqnHBFzH3kHakIJiq5I=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 5b396a780cc..6bd23b272ed 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -26,7 +26,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked from . import setup_integration -from .const import MOCK_ALBUM_WITHOUT_ASSETS from tests.common import MockConfigEntry @@ -143,7 +142,8 @@ async def test_browse_media_get_root( result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 + assert len(result.children) == 3 + media_file = result.children[0] assert isinstance(media_file, BrowseMedia) assert media_file.title == "albums" @@ -151,174 +151,289 @@ async def test_browse_media_get_root( "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums" ) - -async def test_browse_media_get_albums( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - item = MediaSourceItem( - hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums", None - ) - result = await source.async_browse_media(item) - - assert result - assert len(result.children) == 1 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.title == "My Album" - assert media_file.media_content_id == ( - "media-source://immich/" - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" - ) - - -async def test_browse_media_get_albums_error( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media with unknown album.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - # exception in get_albums() - mock_immich.albums.async_get_all_albums.side_effect = ImmichError( - { - "message": "Not found or no album.read access", - "error": "Bad Request", - "statusCode": 400, - "correlationId": "e0hlizyl", - } - ) - - source = await async_get_media_source(hass) - - item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}|albums", None) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - -async def test_browse_media_get_album_items_error( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - - # unknown album - mock_immich.albums.async_get_album_info.return_value = MOCK_ALBUM_WITHOUT_ASSETS - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - # exception in async_get_album_info() - mock_immich.albums.async_get_album_info.side_effect = ImmichError( - { - "message": "Not found or no album.read access", - "error": "Bad Request", - "statusCode": 400, - "correlationId": "e0hlizyl", - } - ) - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - -async def test_browse_media_get_album_items( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert len(result.children) == 2 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4|filename.jpg|image/jpeg" - ) - assert media_file.title == "filename.jpg" - assert media_file.media_class == MediaClass.IMAGE - assert media_file.media_content_type == "image/jpeg" - assert media_file.can_play is False - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg" - ) - media_file = result.children[1] assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4" + assert media_file.title == "people" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|people" ) - assert media_file.title == "filename.mp4" - assert media_file.media_class == MediaClass.VIDEO - assert media_file.media_content_type == "video/mp4" - assert media_file.can_play is True - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg" + + media_file = result.children[2] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "tags" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|tags" ) +@pytest.mark.parametrize( + ("collection", "children"), + [ + ( + "albums", + [{"title": "My Album", "asset_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6"}], + ), + ( + "people", + [ + {"title": "Me", "asset_id": "6176838a-ac5a-4d1f-9a35-91c591d962d8"}, + {"title": "I", "asset_id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f"}, + {"title": "Myself", "asset_id": "a3c83297-684a-4576-82dc-b07432e8a18f"}, + ], + ), + ( + "tags", + [ + { + "title": "Halloween", + "asset_id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + }, + { + "title": "Holidays", + "asset_id": "69bd487f-dc1e-4420-94c6-656f0515773d", + }, + ], + ), + ], +) +async def test_browse_media_collections( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + children: list[dict], +) -> None: + """Test browse through collections.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == len(children) + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == child["title"] + assert media_file.media_content_id == ( + "media-source://immich/" + f"{mock_config_entry.unique_id}|{collection}|" + f"{child['asset_id']}" + ) + + +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_all_albums")), + ("people", ("people", "async_get_all_people")), + ("tags", ("tags", "async_get_all_tags")), + ], +) +async def test_browse_media_collections_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], +) -> None: + """Test browse_media with unknown collection.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_album_info")), + ("people", ("search", "async_get_all_by_person_ids")), + ("tags", ("search", "async_get_all_by_tag_ids")), + ], +) +async def test_browse_media_collection_items_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.parametrize( + ("collection", "collection_id", "children"), + [ + ( + "albums", + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + [ + { + "original_file_name": "filename.jpg", + "asset_id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "filename.mp4", + "asset_id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", + "media_class": MediaClass.VIDEO, + "media_content_type": "video/mp4", + "thumb_mime_type": "image/jpeg", + "can_play": True, + }, + ], + ), + ( + "people", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20250714_201122.jpg", + "asset_id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20250714_201121.jpg", + "asset_id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ( + "tags", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20110306_025024.jpg", + "asset_id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20110306_024053.jpg", + "asset_id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ], +) +async def test_browse_media_collection_get_items( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + collection_id: str, + children: list[dict], +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|{collection_id}", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == len(children) + + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + f"{mock_config_entry.unique_id}|{collection}|{collection_id}|" + f"{child['asset_id']}|{child['original_file_name']}|{child['media_content_type']}" + ) + assert media_file.title == child["original_file_name"] + assert media_file.media_class == child["media_class"] + assert media_file.media_content_type == child["media_content_type"] + assert media_file.can_play is child["can_play"] + assert not media_file.can_expand + assert media_file.thumbnail == ( + f"/immich/{mock_config_entry.unique_id}/" + f"{child['asset_id']}/thumbnail/{child['thumb_mime_type']}" + ) + + async def test_media_view( hass: HomeAssistant, tmp_path: Path, @@ -362,6 +477,22 @@ async def test_media_view( "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", ) + # exception in async_get_person_thumbnail() + mock_immich.people.async_get_person_thumbnail.side_effect = ImmichError( + { + "message": "Not found or no asset.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + # exception in async_play_video_stream() mock_immich.assets.async_play_video_stream.side_effect = ImmichError( { @@ -396,6 +527,24 @@ async def test_media_view( ) assert isinstance(result, web.Response) + mock_immich.people.async_get_person_thumbnail.side_effect = None + mock_immich.people.async_get_person_thumbnail.return_value = b"xxxx" + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + assert isinstance(result, web.Response) + + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/fullsize/image/jpeg", + ) + assert isinstance(result, web.Response) + mock_immich.assets.async_play_video_stream.side_effect = None mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked( b"xxxx" From bf568b22d78c8ad5ec828a24abf9558243fe1615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 20:41:45 -1000 Subject: [PATCH 225/235] Bump onvif-zeep-async to 4.0.2 (#149606) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 63b7437be39..fbb1454ec2a 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==4.0.1", "WSDiscovery==2.1.2"] + "requirements": ["onvif-zeep-async==4.0.2", "WSDiscovery==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 943321cbf31..cc55ca31118 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1591,7 +1591,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.1 +onvif-zeep-async==4.0.2 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de76a345a62..8db38e2466e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1359,7 +1359,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.1 +onvif-zeep-async==4.0.2 # homeassistant.components.opengarage open-garage==0.2.0 From 3c1aa9d9dea96d16921153bd3077468b7263f2e1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 29 Jul 2025 08:52:42 +0200 Subject: [PATCH 226/235] Make exceptions translatable in Tankerkoenig integration (#149611) --- .../components/tankerkoenig/coordinator.py | 18 +++++++++++++++--- .../components/tankerkoenig/manifest.json | 2 +- .../components/tankerkoenig/quality_scale.yaml | 2 +- .../components/tankerkoenig/strings.json | 11 +++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index f1e6bc8c865..dbd826b9359 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -131,19 +131,31 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf stations, err, ) - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except TankerkoenigRateLimitError as err: _LOGGER.warning( "API rate limit reached, consider to increase polling interval" ) - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="rate_limit_reached", + ) from err except (TankerkoenigError, TankerkoenigConnectionError) as err: _LOGGER.debug( "error occur during update of stations %s %s", stations, err, ) - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="station_update_failed", + translation_placeholders={ + "station_ids": ", ".join(stations), + }, + ) from err prices.update(data) diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 5dc75e4cc90..eeb8646bea7 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiotankerkoenig==0.4.2"] } diff --git a/homeassistant/components/tankerkoenig/quality_scale.yaml b/homeassistant/components/tankerkoenig/quality_scale.yaml index 666d927adb5..5def972b636 100644 --- a/homeassistant/components/tankerkoenig/quality_scale.yaml +++ b/homeassistant/components/tankerkoenig/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: status: exempt diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 3f821c7c6fa..43922a930af 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -180,5 +180,16 @@ } } } + }, + "exceptions": { + "rate_limit_reached": { + "message": "You have reached the rate limit for the Tankerkoenig API. Please try to increase the poll interval and reduce the requests." + }, + "invalid_api_key": { + "message": "The provided API key is invalid. Please check your API key." + }, + "station_update_failed": { + "message": "Failed to update station data for station(s) {station_ids}. Please check your network connection." + } } } From 62ee1fbc647ae683eb94080d9506ed3295ea471e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 29 Jul 2025 08:55:32 +0200 Subject: [PATCH 227/235] Remove unnecessary CONF_NAME usage in Habitica integration (#149595) --- homeassistant/components/habitica/config_flow.py | 2 -- homeassistant/components/habitica/coordinator.py | 7 ------- homeassistant/components/habitica/entity.py | 4 ++-- tests/components/habitica/test_config_flow.py | 5 ----- 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 91a13bd7918..65d9be1bb7c 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -164,7 +164,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_API_USER: str(login.id), CONF_API_KEY: login.apiToken, - CONF_NAME: user.profile.name, # needed for api_call action CONF_URL: DEFAULT_URL, CONF_VERIFY_SSL: True, }, @@ -200,7 +199,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): data={ **user_input, CONF_URL: user_input.get(CONF_URL, DEFAULT_URL), - CONF_NAME: user.profile.name, # needed for api_call action }, ) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index d0eb60312b4..b25edc7ceaf 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -23,7 +23,6 @@ from habiticalib import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -106,12 +105,6 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): translation_placeholders={"reason": str(e)}, ) from e - if not self.config_entry.data.get(CONF_NAME): - self.hass.config_entries.async_update_entry( - self.config_entry, - data={**self.config_entry.data, CONF_NAME: user.data.profile.name}, - ) - async def _async_update_data(self) -> HabiticaData: try: user = (await self.habitica.get_user()).data diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 692ea5e5ac1..6d320f93517 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from yarl import URL -from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,7 +37,7 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): entry_type=DeviceEntryType.SERVICE, manufacturer=MANUFACTURER, model=NAME, - name=coordinator.config_entry.data[CONF_NAME], + name=coordinator.data.user.profile.name, configuration_url=( URL(coordinator.config_entry.data[CONF_URL]) / "profile" diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 5ec998ec82e..63001157695 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -16,7 +16,6 @@ from homeassistant.components.habitica.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, - CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, @@ -96,7 +95,6 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -151,7 +149,6 @@ async def test_form_login_errors( CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -219,7 +216,6 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -275,7 +271,6 @@ async def test_form_advanced_errors( CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER From 45ec9c7dad7e253285975176bc99d6ebc99cb078 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:37:32 +0200 Subject: [PATCH 228/235] Refactor coordinator setup in Iron OS (#149600) --- homeassistant/components/iron_os/__init__.py | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 7a0cf8eaa53..01ce0918459 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -9,9 +9,7 @@ from pynecil import IronOSUpdate, Pynecil from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -33,8 +31,6 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) @@ -42,19 +38,15 @@ IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up IronOS firmware update coordinator.""" - - session = async_get_clientsession(hass) - github = IronOSUpdate(session) - - hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) - await hass.data[IRON_OS_KEY].async_request_refresh() - return True - - async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool: """Set up IronOS from a config entry.""" + if IRON_OS_KEY not in hass.data: + session = async_get_clientsession(hass) + github = IronOSUpdate(session) + + hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) + await hass.data[IRON_OS_KEY].async_request_refresh() + if TYPE_CHECKING: assert entry.unique_id @@ -77,4 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if not hass.config_entries.async_loaded_entries(DOMAIN): + await hass.data[IRON_OS_KEY].async_shutdown() + hass.data.pop(IRON_OS_KEY) + return unload_ok From 2e728eb7de220e2aede1fcead1b744d36280a3c4 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:38:50 +0200 Subject: [PATCH 229/235] Bump aioautomower to 2.1.1 (#149585) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index f5de5a3dff8..a0f25b1df4c 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.1.0"] + "requirements": ["aioautomower==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc55ca31118..4270e12d816 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.0 +aioautomower==2.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8db38e2466e..50fac6e7ea8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.0 +aioautomower==2.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 692a1119a6b7638fa0ea79121a9bf512ff751c58 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:29:07 +0200 Subject: [PATCH 230/235] Adjust suggested display precision on Volvo distance sensors (#149593) --- homeassistant/components/volvo/sensor.py | 11 +++ .../volvo/snapshots/test_sensor.ambr | 78 ++++++++++++------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index b8949f5e73d..dd982238a47 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -146,6 +146,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # statistics endpoint VolvoSensorDescription( @@ -154,6 +155,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # vehicle endpoint VolvoSensorDescription( @@ -170,6 +172,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # energy state endpoint VolvoSensorDescription( @@ -240,6 +243,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # statistics endpoint VolvoSensorDescription( @@ -248,6 +252,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # diagnostics endpoint VolvoSensorDescription( @@ -256,6 +261,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # diagnostics endpoint VolvoSensorDescription( @@ -280,6 +286,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME_STORAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), # odometer endpoint VolvoSensorDescription( @@ -288,12 +295,14 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, ), # energy state endpoint VolvoSensorDescription( key="target_battery_charge_level", api_field="targetBatteryChargeLevel", native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, ), # diagnostics endpoint VolvoSensorDescription( @@ -311,6 +320,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), # statistics endpoint VolvoSensorDescription( @@ -319,6 +329,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), ) diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 0f79ab5ca07..d5346cf9cd8 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -23,6 +23,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -550,7 +553,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -606,7 +609,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -718,7 +721,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -771,6 +774,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -935,7 +941,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -991,7 +997,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1099,7 +1105,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1155,7 +1161,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1210,6 +1216,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -1328,7 +1337,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1384,7 +1393,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1440,7 +1449,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -1496,7 +1505,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -1664,7 +1673,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1720,7 +1729,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1828,7 +1837,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1884,7 +1893,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1939,6 +1948,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -2466,7 +2478,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -2522,7 +2534,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -2634,7 +2646,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -2687,6 +2699,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -2851,7 +2866,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -2907,7 +2922,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3015,7 +3030,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3071,7 +3086,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3126,6 +3141,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -3244,7 +3262,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3300,7 +3318,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3356,7 +3374,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -3412,7 +3430,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -3580,7 +3598,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3636,7 +3654,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3744,7 +3762,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3800,7 +3818,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , From 87400c6a1701b3dda756ccf143d3088f52bc5da4 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 29 Jul 2025 12:59:30 +0200 Subject: [PATCH 231/235] Bump odp-amsterdam to v6.1.2 (#149617) --- homeassistant/components/garages_amsterdam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 7652b4b6f3b..e74deac25c4 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==6.1.1"] + "requirements": ["odp-amsterdam==6.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4270e12d816..356e81fe73a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1570,7 +1570,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.1.1 +odp-amsterdam==6.1.2 # homeassistant.components.oem oemthermostat==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50fac6e7ea8..e8d7c565659 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1341,7 +1341,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.1.1 +odp-amsterdam==6.1.2 # homeassistant.components.ohme ohme==1.5.1 From c7271d1af925742b71a142a054c43ae15649719b Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:50:31 +0300 Subject: [PATCH 232/235] Add OSO Energy Custom Away Mode Service (#149612) --- homeassistant/components/osoenergy/icons.json | 3 +++ .../components/osoenergy/services.yaml | 14 +++++++++++ .../components/osoenergy/strings.json | 10 ++++++++ .../components/osoenergy/water_heater.py | 18 ++++++++++++++ .../components/osoenergy/test_water_heater.py | 24 +++++++++++++++++++ 5 files changed, 69 insertions(+) diff --git a/homeassistant/components/osoenergy/icons.json b/homeassistant/components/osoenergy/icons.json index 42d1f2cc480..be1bf0534db 100644 --- a/homeassistant/components/osoenergy/icons.json +++ b/homeassistant/components/osoenergy/icons.json @@ -22,6 +22,9 @@ "set_v40_min": { "service": "mdi:car-coolant-level" }, + "turn_away_mode_on": { + "service": "mdi:beach" + }, "turn_off": { "service": "mdi:water-boiler-off" }, diff --git a/homeassistant/components/osoenergy/services.yaml b/homeassistant/components/osoenergy/services.yaml index 6c8f5512215..4cd91f3285f 100644 --- a/homeassistant/components/osoenergy/services.yaml +++ b/homeassistant/components/osoenergy/services.yaml @@ -237,6 +237,20 @@ set_v40_min: max: 550 step: 1 unit_of_measurement: L +turn_away_mode_on: + target: + entity: + domain: water_heater + fields: + duration_days: + required: true + example: 7 + selector: + number: + min: 1 + max: 365 + step: 1 + unit_of_measurement: days turn_off: target: entity: diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 465f3f15c6b..60b67731eac 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -209,6 +209,16 @@ } } }, + "turn_away_mode_on": { + "name": "Set away mode", + "description": "Turns away mode on for the heater", + "fields": { + "duration_days": { + "name": "Duration in days", + "description": "Number of days to keep away mode active (1-365)" + } + } + }, "turn_off": { "name": "Turn off heating", "description": "Turns off heating for one hour or until min temperature is reached", diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index c271330bacd..1f4ad9d06c5 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -26,6 +26,7 @@ from homeassistant.util.json import JsonValueType from .const import DOMAIN from .entity import OSOEnergyEntity +ATTR_DURATION_DAYS = "duration_days" ATTR_UNTIL_TEMP_LIMIT = "until_temp_limit" ATTR_V40MIN = "v40_min" CURRENT_OPERATION_MAP: dict[str, Any] = { @@ -44,6 +45,7 @@ CURRENT_OPERATION_MAP: dict[str, Any] = { SERVICE_GET_PROFILE = "get_profile" SERVICE_SET_PROFILE = "set_profile" SERVICE_SET_V40MIN = "set_v40_min" +SERVICE_TURN_AWAY_MODE_ON = "turn_away_mode_on" SERVICE_TURN_OFF = "turn_off" SERVICE_TURN_ON = "turn_on" @@ -69,6 +71,16 @@ async def async_setup_entry( supports_response=SupportsResponse.ONLY, ) + platform.async_register_entity_service( + SERVICE_TURN_AWAY_MODE_ON, + { + vol.Required(ATTR_DURATION_DAYS): vol.All( + vol.Coerce(int), vol.Range(min=1, max=365) + ), + }, + OSOEnergyWaterHeater.async_oso_turn_away_mode_on.__name__, + ) + service_set_profile_schema = cv.make_entity_service_schema( { vol.Optional(f"hour_{hour:02d}"): vol.All( @@ -280,6 +292,12 @@ class OSOEnergyWaterHeater( """Handle the service call.""" await self.osoenergy.hotwater.set_v40_min(self.entity_data, v40_min) + async def async_oso_turn_away_mode_on(self, duration_days: int) -> None: + """Enable away mode with duration.""" + await self.osoenergy.hotwater.enable_holiday_mode( + self.entity_data, duration_days + ) + async def async_oso_turn_off(self, until_temp_limit) -> None: """Handle the service call.""" await self.osoenergy.hotwater.turn_off(self.entity_data, until_temp_limit) diff --git a/tests/components/osoenergy/test_water_heater.py b/tests/components/osoenergy/test_water_heater.py index 270fc3c58f0..dd3a08dd24f 100644 --- a/tests/components/osoenergy/test_water_heater.py +++ b/tests/components/osoenergy/test_water_heater.py @@ -7,11 +7,13 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.osoenergy.const import DOMAIN from homeassistant.components.osoenergy.water_heater import ( + ATTR_DURATION_DAYS, ATTR_UNTIL_TEMP_LIMIT, ATTR_V40MIN, SERVICE_GET_PROFILE, SERVICE_SET_PROFILE, SERVICE_SET_V40MIN, + SERVICE_TURN_AWAY_MODE_ON, ) from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, @@ -310,3 +312,25 @@ async def test_turn_away_mode_off( ) mock_osoenergy_client().hotwater.disable_holiday_mode.assert_called_once_with(ANY) + + +async def test_oso_set_away_mode_on( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test enabling away mode.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_AWAY_MODE_ON, + { + ATTR_ENTITY_ID: "water_heater.test_device", + ATTR_DURATION_DAYS: 10, + }, + blocking=True, + ) + + mock_osoenergy_client().hotwater.enable_holiday_mode.assert_called_once_with( + ANY, 10 + ) From 378c3af9dfd346008a9c752515f14c736ae6c75e Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:51:32 +0200 Subject: [PATCH 233/235] Bump qbusmqttapi to 1.4.2 (#149622) --- homeassistant/components/qbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index 17101da7c33..feffa6e492c 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -13,5 +13,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.3.0"] + "requirements": ["qbusmqttapi==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 356e81fe73a..df48a2f43f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2624,7 +2624,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.3.0 +qbusmqttapi==1.4.2 # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8d7c565659..3628cac38e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2176,7 +2176,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.3.0 +qbusmqttapi==1.4.2 # homeassistant.components.qingping qingping-ble==0.10.0 From 3d6f868cbcbcda635e832665db44faa10fcba51c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 29 Jul 2025 13:57:40 +0200 Subject: [PATCH 234/235] Bump zwave-js-server-python to 0.67.0 (#149616) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 4c9ef784077..2cad8df3805 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.66.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.67.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index df48a2f43f3..1359413cd3a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3212,7 +3212,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.66.0 +zwave-js-server-python==0.67.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3628cac38e9..31004789f97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2647,7 +2647,7 @@ zeversolar==0.3.2 zha==0.0.62 # homeassistant.components.zwave_js -zwave-js-server-python==0.66.0 +zwave-js-server-python==0.67.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From ff7c1253348367174a12dc9307db2f73653e5a68 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 29 Jul 2025 15:19:08 +0200 Subject: [PATCH 235/235] Upgrade Homee quality scale to silver (#149194) --- homeassistant/components/homee/manifest.json | 2 +- .../components/homee/quality_scale.yaml | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 16169676835..9cac876f325 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["homee"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["pyHomee==1.2.10"] } diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index 906218cf823..5a8f987c1f9 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -28,16 +28,19 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: | + The integration does not have options. + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo - test-coverage: todo + reauthentication-flow: done + test-coverage: done # Gold devices: done @@ -49,16 +52,16 @@ rules: docs-known-limitations: todo docs-supported-devices: todo docs-supported-functions: todo - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: todo dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo